diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs
--- a/crates/ui-egui/src/app.rs
+++ b/crates/ui-egui/src/app.rs
@@ -121,6 +121,7 @@ impl PhotocraftApp {
             type_clicks: None,
             type_transform_preview: None,
             type_font_preview: None,
+            color_picker_preview: None,
             guide_drag: None,
             layer_box: None,
             crop: Default::default(),
diff --git a/crates/ui-egui/src/canvas/display.rs b/crates/ui-egui/src/canvas/display.rs
--- a/crates/ui-egui/src/canvas/display.rs
+++ b/crates/ui-egui/src/canvas/display.rs
@@ -110,6 +110,9 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Ar
     if let Some(shown) = crate::type_panels_ui::color_picker::display_doc(app, idx) {
         return shown;
     }
+    if let Some(shown) = crate::color_picker_preview::display_doc(app, idx) {
+        return shown;
+    }
     if let Some(shown) = crate::solid_fill_ui::display_doc(app, idx) {
         return shown;
     }
diff --git a/crates/ui-egui/src/canvas/textures.rs b/crates/ui-egui/src/canvas/textures.rs
--- a/crates/ui-egui/src/canvas/textures.rs
+++ b/crates/ui-egui/src/canvas/textures.rs
@@ -389,6 +389,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 colours under a text or stop Color Picker (and the document): the target layers' bounds.
+    if seen.0 == now.0
+        && let Some(st) = app.session.documents().get(idx)
+        && let Some(r) = crate::color_picker_preview::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 hovered fonts (and the document): the text layer's old/new ink bounds.
     if seen.0 == now.0
         && let Some(st) = app.session.documents().get(idx)
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -992,6 +992,7 @@ pub(super) mod camera_raw_preview_ui;
 pub(super) mod camera_raw_scope_ui;
 pub mod camera_raw_ui;
 pub mod channel_options_ui;
+pub(crate) mod color_picker_preview;
 pub mod color_picker_ui;
 pub mod color_range_ui;
 pub mod color_table_ui;
diff --git a/crates/ui-egui/src/dialogs/color_picker_preview.rs b/crates/ui-egui/src/dialogs/color_picker_preview.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/dialogs/color_picker_preview.rs
@@ -0,0 +1,179 @@
+//! Live canvas preview for Color Pickers whose OK edits the document through a command: the
+//! Type tool's text colour (`type.setStyle`) and a gradient fill's colour stop
+//! (`gradient.fill.stop`).
+//!
+//! Photoshop shows the colour under the picker's cursor on the canvas while its Color Picker is
+//! open, before OK. This runs the command OK will run, with the picker's colour, on a copy of the
+//! document and shows that copy on the canvas. The session's document, its revision and its
+//! history are never touched: Cancel just stops showing the copy, and OK commits the same edit as
+//! before in one history step. (Solid Color and shape fills have their own previews.)
+
+use std::collections::VecDeque;
+use std::sync::Arc;
+
+use photocraft_doc::{DocId, Document, LayerId};
+use photocraft_geom::Rect;
+use serde_json::{Map, Value};
+
+use crate::PhotocraftApp;
+use crate::color_picker_ui::{hex, parse_hex};
+
+/// The commands previewed: the text colour (`type_tool::open_color_picker`) and a gradient fill
+/// layer's colour stop (`gradient_ui::edit_stop_color`). Only commands that edit the active
+/// document belong here: a preset editor's picker (`gradient.presets.stop`) changes no pixels.
+const COMMANDS: [&str; 2] = ["type.setStyle", photocraft_engine::gradient_fill_cmds::STOP];
+/// Preview cache keys carry this bit so they never equal a committed document's key (0).
+const TAG: u64 = 1 << 60;
+/// How many previewed colours keep their ink bounds for canvas invalidation.
+const AREAS: usize = 128;
+
+/// The last previewed copy, and the ink bounds of every recent preview of the same document
+/// revision (so the canvas can refresh only the target layers between two colours).
+pub(crate) struct Preview {
+    document: DocId,
+    revision: u64,
+    key: u64,
+    shown: Option<Arc<Document>>,
+    original_area: Rect,
+    areas: VecDeque<(u64, Rect)>,
+}
+
+/// The previewed command whose Color Picker these `fields` belong to, if any.
+fn command_of(fields: &Map<String, Value>) -> Option<&'static str> {
+    if fields.get("__colorPicker").and_then(Value::as_str) != Some("command") {
+        return None;
+    }
+    let command = fields.get("__command").and_then(Value::as_str)?;
+    COMMANDS.into_iter().find(|c| *c == command)
+}
+
+/// Whether `fields` belong to a Color Picker this module previews.
+pub(crate) fn owns(fields: &Map<String, Value>) -> bool {
+    command_of(fields).is_some()
+}
+
+/// The command and params OK would run for these picker fields, or `None` when the picker
+/// still shows its opening colour (nothing to preview) or its colour is not valid.
+fn preview_params(fields: &Map<String, Value>) -> Option<(&'static str, Value)> {
+    let command = command_of(fields)?;
+    let color = fields.get("color").and_then(Value::as_str).and_then(parse_hex).map(hex)?;
+    let orig = fields.get("__orig").and_then(Value::as_str).and_then(parse_hex).map(hex);
+    if orig.as_deref() == Some(color.as_str()) {
+        return None;
+    }
+    let mut params = fields.get("__params")?.as_object()?.clone();
+    // The preview is a throwaway session: it has no typing session to merge into.
+    params.remove("coalesce");
+    params.insert("color".into(), Value::String(color));
+    Some((command, Value::Object(params)))
+}
+
+/// `doc` with `params` applied through the real command, so the preview is exactly what OK
+/// commits. `Err` when the command refuses (the canvas then keeps showing the document).
+pub(crate) fn preview_document(doc: &Document, command: &str, params: Value) -> Result<Document, String> {
+    let mut s = photocraft_engine::Session::new();
+    s.add_document(doc.clone(), None);
+    s.execute(command, params).map_err(|e| e.to_string())?;
+    let shown = s.active().ok_or("the preview has no document")?.doc.as_ref().clone();
+    Ok(photocraft_engine::mode_cmds::display_document(&shown).unwrap_or(shown))
+}
+
+/// The layers `params` recolour: the one being edited or the fill layer (`layer`), else every
+/// selected type layer (`layers`), as `type_tool::formatting_params` builds them.
+fn target_layers(params: &Value) -> Vec<LayerId> {
+    let one = params.get("layer").and_then(Value::as_u64);
+    let many = params.get("layers").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64);
+    one.into_iter().chain(many).map(LayerId).collect()
+}
+
+/// The bounds of `layers` in `doc` (empty when none has pixels yet).
+fn layer_area(doc: &Document, layers: &[LayerId]) -> Rect {
+    layers.iter().filter_map(|id| doc.layer(*id)?.surface()).fold(Rect::EMPTY, |r, s| r.union(&s.tile_bounds()))
+}
+
+/// The document to draw while a previewed Color Picker is the top dialog: a copy with the picked
+/// colour applied. `None` (draw the document itself) without such a picker, while it shows its
+/// opening colour, for another document, or when the command refuses the preview.
+pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc<Document>, u64)> {
+    let Some(d) = app.ui.dialogs.last().filter(|d| owns(&d.fields)) else {
+        forget_shown(app);
+        return None;
+    };
+    // The picker edits the active document: never show its colour in another tab.
+    if app.session.active_index() != Some(idx) {
+        return None;
+    }
+    let Some((command, params)) = preview_params(&d.fields) else {
+        forget_shown(app);
+        return None;
+    };
+    let dialog = d.id;
+    let opened_on = d.fields.get("__document").and_then(Value::as_u64);
+    let st = app.session.documents().get(idx)?;
+    // Another document became active while the picker was open: its layer ids mean nothing here.
+    if opened_on.is_some_and(|id| id != st.doc.id.0) {
+        forget_shown(app);
+        return None;
+    }
+    let (document, revision) = (st.doc.id, st.revision);
+    let layers = target_layers(&params);
+    let key = (egui::Id::unique((document.0, revision, dialog, params.to_string())).value() & (TAG - 1)) | TAG;
+
+    // A new document revision starts a new set of areas; the same one keeps them.
+    let same = app.color_picker_preview.as_ref().is_some_and(|p| p.document == document && p.revision == revision);
+    if !same {
+        let original_area = layer_area(&st.doc, &layers);
+        app.color_picker_preview = Some(Preview { document, revision, key: 0, shown: None, original_area, areas: VecDeque::new() });
+    }
+    if let Some(p) = app.color_picker_preview.as_ref()
+        && p.key == key
+        && let Some(shown) = &p.shown
+    {
+        return Some((shown.clone(), key));
+    }
+
+    let doc = st.doc.clone();
+    let shown = match preview_document(&doc, command, params) {
+        Ok(shown) => Arc::new(shown),
+        Err(_) => {
+            // OK reports the refusal; the preview quietly shows the document as it is.
+            forget_shown(app);
+            return None;
+        }
+    };
+    let p = app.color_picker_preview.as_mut()?;
+    // A colour change doesn't move the content, but the copy's layer may not be rendered yet
+    // (empty bounds): its area always covers the layer's bounds in the document.
+    let area = layer_area(&shown, &layers).union(&p.original_area);
+    if p.areas.len() >= AREAS {
+        p.areas.pop_front();
+    }
+    p.areas.push_back((key, area));
+    p.key = key;
+    p.shown = Some(shown.clone());
+    Some((shown, key))
+}
+
+/// Drop the previewed copy (the picker closed or went back to its opening colour). The areas
+/// stay, so the canvas can still refresh just the text layer on its way back to the document.
+fn forget_shown(app: &mut PhotocraftApp) {
+    if let Some(p) = app.color_picker_preview.as_mut() {
+        p.shown = None;
+        p.key = 0;
+    }
+}
+
+/// The canvas area that differs between the frames keyed `before` and `after` (0 is the document
+/// itself) of revision `revision`: the target layers' bounds. `None` when either isn't ours.
+pub(crate) fn damage(app: &PhotocraftApp, doc: DocId, revision: u64, before: u64, after: u64) -> Option<Rect> {
+    if before == after {
+        return None;
+    }
+    let p = app.color_picker_preview.as_ref().filter(|p| p.document == doc && p.revision == revision)?;
+    let area = |key: u64| if key == 0 { Some(p.original_area) } else { p.areas.iter().find(|(k, _)| *k == key).map(|(_, r)| *r) };
+    // Empty bounds would tell the canvas nothing changed: let it refresh everything instead.
+    Some(area(before)?.union(&area(after)?)).filter(|r| !r.is_empty())
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/ui-egui/src/dialogs/color_picker_preview/tests.rs b/crates/ui-egui/src/dialogs/color_picker_preview/tests.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/dialogs/color_picker_preview/tests.rs
@@ -0,0 +1,315 @@
+use super::*;
+use photocraft_doc::{LayerContent, TextLayer};
+use serde_json::json;
+
+fn app_at(depth: u32) -> PhotocraftApp {
+    let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
+    app.run("file.new", json!({"width": 400, "height": 200, "depth": depth})).unwrap();
+    app.sync_views();
+    app.ui.tool = crate::state::Tool::Type;
+    app
+}
+
+fn new_type_layer(app: &mut PhotocraftApp, text: &str) -> LayerId {
+    LayerId(app.run("type.create", json!({"text": text, "color": "#000000"})).unwrap()["layer"].as_u64().unwrap())
+}
+
+fn text_in(doc: &Document, id: LayerId) -> TextLayer {
+    match &doc.layer(id).unwrap().content {
+        LayerContent::Text(t) => t.clone(),
+        _ => panic!("not a type layer"),
+    }
+}
+
+/// The colour of character `ci` (a char index) in `doc`'s type layer `id`.
+fn rgb_at(doc: &Document, id: LayerId, ci: usize) -> [u8; 4] {
+    let t = text_in(doc, id);
+    let b = t.text.char_indices().nth(ci).map_or(t.text.len(), |(b, _)| b);
+    let mut at = 0;
+    for r in t.char_runs() {
+        if b < at + r.len {
+            return r.style.color.to_rgba8();
+        }
+        at += r.len;
+    }
+    [0; 4]
+}
+
+/// Open the text Color Picker and move it to `color`, as dragging in its field does.
+fn pick(app: &mut PhotocraftApp, color: &str) -> u64 {
+    let dialog = crate::type_tool::open_color_picker(app, [0.0; 3]);
+    set_color(app, color);
+    dialog
+}
+
+fn set_color(app: &mut PhotocraftApp, color: &str) {
+    app.ui.dialogs.last_mut().unwrap().fields.insert("color".into(), json!(color));
+}
+
+/// What the session holds: document, revision, dirty flag and history length.
+fn committed(app: &PhotocraftApp) -> (Arc<Document>, u64, bool, usize) {
+    let st = app.session.active().unwrap();
+    (st.doc.clone(), st.revision, st.is_dirty(), st.history.entries().len())
+}
+
+#[test]
+fn the_canvas_shows_the_picked_colour_before_ok_without_touching_the_session() {
+    for depth in [8, 16, 32] {
+        let mut app = app_at(depth);
+        let id = new_type_layer(&mut app, "Hello");
+        let (doc, revision, dirty, history) = committed(&app);
+        pick(&mut app, "#e51c33");
+
+        let (shown, key) = display_doc(&mut app, 0).expect("a live preview");
+        assert_ne!(key & TAG, 0, "preview keys never equal the document's");
+        assert_eq!(rgb_at(&shown, id, 0), [229, 28, 51, 255], "{depth}-bit: the canvas shows the picked colour");
+        assert_eq!(rgb_at(&shown, id, 4), [229, 28, 51, 255]);
+        let after = committed(&app);
+        assert!(Arc::ptr_eq(&after.0, &doc), "{depth}-bit: the document is not edited");
+        assert_eq!((after.1, after.2, after.3), (revision, dirty, history), "{depth}-bit: no revision, dirty flag or history step");
+        assert_eq!(rgb_at(&app.session.active().unwrap().doc, id, 0), [0, 0, 0, 255]);
+    }
+}
+
+#[test]
+fn cancel_leaves_the_text_and_history_as_they_were_and_stops_the_preview() {
+    let mut app = app_at(8);
+    let id = new_type_layer(&mut app, "Hello");
+    let (doc, revision, dirty, history) = committed(&app);
+    let dialog = pick(&mut app, "#00ff00");
+    assert!(display_doc(&mut app, 0).is_some());
+
+    app.ui.close_dialog(dialog).unwrap();
+    assert!(display_doc(&mut app, 0).is_none(), "Cancel: the canvas draws the document again");
+    let after = committed(&app);
+    assert!(Arc::ptr_eq(&after.0, &doc));
+    assert_eq!((after.1, after.2, after.3), (revision, dirty, history));
+    assert_eq!(rgb_at(&after.0, id, 0), [0, 0, 0, 255]);
+}
+
+#[test]
+fn ok_commits_exactly_what_the_preview_showed_in_one_history_step() {
+    let mut app = app_at(8);
+    let id = new_type_layer(&mut app, "Hello");
+    let history = committed(&app).3;
+    let dialog = pick(&mut app, "#ff8800");
+    let (shown, _) = display_doc(&mut app, 0).unwrap();
+
+    crate::dialogs::confirm(&mut app, dialog).unwrap();
+    let doc = app.session.active().unwrap().doc.clone();
+    assert_eq!(text_in(&doc, id).char_runs(), text_in(&shown, id).char_runs(), "the preview is the committed edit");
+    assert_eq!(committed(&app).3, history + 1);
+    assert!(display_doc(&mut app, 0).is_none(), "no preview once the picker has closed");
+}
+
+#[test]
+fn the_preview_follows_every_colour_and_stops_at_the_opening_one() {
+    let mut app = app_at(8);
+    let id = new_type_layer(&mut app, "Hello");
+    pick(&mut app, "#ff0000");
+    let (red, red_key) = display_doc(&mut app, 0).unwrap();
+    assert_eq!(rgb_at(&red, id, 0), [255, 0, 0, 255]);
+    let (again, again_key) = display_doc(&mut app, 0).unwrap();
+    assert!(Arc::ptr_eq(&red, &again) && red_key == again_key, "an unchanged colour reuses its preview");
+
+    set_color(&mut app, "#0000ff");
+    let (blue, blue_key) = display_doc(&mut app, 0).unwrap();
+    assert_ne!(blue_key, red_key);
+    assert_eq!(rgb_at(&blue, id, 0), [0, 0, 255, 255]);
+
+    // Back to the colour the picker opened on: nothing to preview, the document shows.
+    set_color(&mut app, "#000000");
+    assert!(display_doc(&mut app, 0).is_none());
+}
+
+#[test]
+fn only_the_selected_characters_are_previewed_in_the_new_colour() {
+    let mut app = app_at(8);
+    let id = new_type_layer(&mut app, "Hello world");
+    app.ui.text_edit = Some(crate::state::TextEdit {
+        layer: id.0,
+        document: None,
+        caret: 11,
+        anchor: 6,
+        session: "type-color-preview-test".into(),
+        created: false,
+        mask: false,
+        dragging: false,
+        resize: None,
+        preedit: None,
+    });
+    let history = committed(&app).3;
+    pick(&mut app, "#00ff00");
+    let (shown, _) = display_doc(&mut app, 0).unwrap();
+    assert_eq!(rgb_at(&shown, id, 5), [0, 0, 0, 255], "outside the selection");
+    assert_eq!(rgb_at(&shown, id, 6), [0, 255, 0, 255], "inside the selection");
+    assert_eq!(rgb_at(&shown, id, 10), [0, 255, 0, 255]);
+    assert_eq!(committed(&app).3, history, "the typing session's history is untouched");
+}
+
+#[test]
+fn no_preview_for_other_pickers_other_tabs_or_bad_fields() {
+    let mut app = app_at(8);
+    let id = new_type_layer(&mut app, "Hello");
+
+    // The foreground Color Picker previews nothing on the canvas.
+    let fg = crate::color_picker_ui::open(&mut app, "foreground");
+    set_color(&mut app, "#ff0000");
+    assert!(display_doc(&mut app, 0).is_none());
+    app.ui.close_dialog(fg).unwrap();
+
+    // Another document's tab never shows this picker's colour.
+    pick(&mut app, "#ff0000");
+    assert!(display_doc(&mut app, 1).is_none());
+
+    // Malformed fields: no preview, and no panic.
+    for bad in [json!("not a colour"), json!(""), json!(42), json!(null)] {
+        app.ui.dialogs.last_mut().unwrap().fields.insert("color".into(), bad);
+        assert!(display_doc(&mut app, 0).is_none());
+    }
+    set_color(&mut app, "#ff0000");
+    let f = &mut app.ui.dialogs.last_mut().unwrap().fields;
+    f.insert("__params".into(), json!({"layer": 999_999}));
+    assert!(display_doc(&mut app, 0).is_none(), "a layer that is gone: the command refuses, the document shows");
+    let f = &mut app.ui.dialogs.last_mut().unwrap().fields;
+    f.insert("__params".into(), json!("not an object"));
+    assert!(display_doc(&mut app, 0).is_none());
+    app.ui.dialogs.last_mut().unwrap().fields.remove("__params");
+    assert!(display_doc(&mut app, 0).is_none());
+    assert_eq!(rgb_at(&app.session.active().unwrap().doc, id, 0), [0, 0, 0, 255]);
+}
+
+#[test]
+fn damage_between_previews_is_the_text_layer_only() {
+    let mut app = app_at(8);
+    let id = new_type_layer(&mut app, "Hello");
+    let (doc_id, revision) = {
+        let st = app.session.active().unwrap();
+        (st.doc.id, st.revision)
+    };
+    let bounds = app.session.active().unwrap().doc.layer(id).unwrap().surface().unwrap().tile_bounds();
+    pick(&mut app, "#ff0000");
+    let (_, red) = display_doc(&mut app, 0).unwrap();
+    set_color(&mut app, "#0000ff");
+    let (_, blue) = display_doc(&mut app, 0).unwrap();
+
+    let between = damage(&app, doc_id, revision, red, blue).expect("both previews are ours");
+    assert_eq!(between, bounds);
+    assert_eq!(damage(&app, doc_id, revision, 0, red), Some(bounds), "document to preview");
+    assert_eq!(damage(&app, doc_id, revision, blue, 0), Some(bounds), "preview back to the document (Cancel)");
+    assert!(damage(&app, doc_id, revision, red, red).is_none());
+    assert!(damage(&app, doc_id, revision + 1, red, blue).is_none(), "another revision isn't ours");
+    assert!(damage(&app, doc_id, revision, red, 12_345 | TAG).is_none(), "an unknown key isn't ours");
+}
+
+#[test]
+fn owns_only_the_previewed_pickers() {
+    let mut f = Map::new();
+    assert!(!owns(&f));
+    f.insert("__colorPicker".into(), json!("command"));
+    f.insert("__command".into(), json!("type.setStyle"));
+    assert!(owns(&f));
+    f.insert("__command".into(), json!("gradient.fill.stop"));
+    assert!(owns(&f), "a gradient fill layer's colour stop");
+    f.insert("__command".into(), json!("gradient.presets.stop"));
+    assert!(!owns(&f), "a preset's stop edits no pixels");
+    f.insert("__command".into(), json!("layer.solidFill.set"));
+    assert!(!owns(&f), "Solid Color fills have their own preview");
+    f.insert("__colorPicker".into(), json!(7));
+    assert!(!owns(&f));
+}
+
+#[test]
+fn every_selected_type_layer_is_previewed_and_refreshed() {
+    let mut app = app_at(8);
+    let a = new_type_layer(&mut app, "Hello");
+    let b = new_type_layer(&mut app, "World");
+    app.run("layer.select", json!({"layer": a.0, "mode": "add"})).unwrap();
+    let (doc_id, revision, bounds) = {
+        let st = app.session.active().unwrap();
+        let area = |id: LayerId| st.doc.layer(id).unwrap().surface().unwrap().tile_bounds();
+        (st.doc.id, st.revision, area(a).union(&area(b)))
+    };
+    pick(&mut app, "#ff0000");
+    let (shown, red) = display_doc(&mut app, 0).unwrap();
+    assert_eq!(rgb_at(&shown, a, 0), [255, 0, 0, 255]);
+    assert_eq!(rgb_at(&shown, b, 0), [255, 0, 0, 255]);
+    // The recoloured copies may re-render with wider tiles; the damage must cover both layers.
+    let d = damage(&app, doc_id, revision, 0, red).expect("both layers are ours");
+    assert_eq!(d.union(&bounds), d, "covers both layers' bounds: {d:?} vs {bounds:?}");
+}
+
+/// Colour stop `i` (by location) of gradient fill layer `id`, as 8-bit RGB.
+fn stop_rgb(doc: &Document, id: LayerId, i: usize) -> [u8; 3] {
+    let LayerContent::Fill(photocraft_doc::Fill::Gradient { stops, .. }) = &doc.layer(id).unwrap().content else { panic!("not a gradient fill") };
+    let mut sorted = stops.clone();
+    sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
+    sorted[i].1.to_rgb().map(|v| (v * 255.0).round() as u8)
+}
+
+fn new_gradient_fill(app: &mut PhotocraftApp) -> LayerId {
+    LayerId(app.run("layer.newFillLayer.gradient", json!({"from": "#000000", "to": "#ffffff"})).unwrap()["layer"].as_u64().unwrap())
+}
+
+#[test]
+fn a_gradient_stop_colour_is_previewed_before_ok_and_cancel_changes_nothing() {
+    for depth in [8, 16, 32] {
+        let mut app = app_at(depth);
+        let id = new_gradient_fill(&mut app);
+        let (doc, revision, dirty, history) = committed(&app);
+        crate::gradient_ui::edit_stop_color(&mut app, id, 0);
+        let dialog = app.ui.dialogs.last().unwrap().id;
+        assert!(owns(&app.ui.dialogs.last().unwrap().fields), "{depth}-bit: the stop picker is previewed");
+        set_color(&mut app, "#ff0000");
+
+        let (shown, _) = display_doc(&mut app, 0).expect("a live preview");
+        assert_eq!(stop_rgb(&shown, id, 0), [255, 0, 0], "{depth}-bit: the canvas shows the picked stop colour");
+        assert_eq!(stop_rgb(&shown, id, 1), [255, 255, 255], "{depth}-bit: the other stop is unchanged");
+        let after = committed(&app);
+        assert!(Arc::ptr_eq(&after.0, &doc));
+        assert_eq!((after.1, after.2, after.3), (revision, dirty, history), "{depth}-bit: the session is untouched");
+
+        app.ui.close_dialog(dialog).unwrap();
+        assert!(display_doc(&mut app, 0).is_none(), "{depth}-bit: Cancel shows the document again");
+        assert_eq!(stop_rgb(&app.session.active().unwrap().doc, id, 0), [0, 0, 0]);
+    }
+}
+
+#[test]
+fn ok_commits_the_previewed_gradient_stop_in_one_history_step() {
+    let mut app = app_at(8);
+    let id = new_gradient_fill(&mut app);
+    let history = committed(&app).3;
+    crate::gradient_ui::edit_stop_color(&mut app, id, 1);
+    let dialog = app.ui.dialogs.last().unwrap().id;
+    set_color(&mut app, "#00ff00");
+    let (shown, _) = display_doc(&mut app, 0).unwrap();
+
+    crate::dialogs::confirm(&mut app, dialog).unwrap();
+    let doc = app.session.active().unwrap().doc.clone();
+    assert_eq!(stop_rgb(&doc, id, 1), stop_rgb(&shown, id, 1), "the preview is the committed edit");
+    assert_eq!(stop_rgb(&doc, id, 1), [0, 255, 0]);
+    assert_eq!(committed(&app).3, history + 1);
+}
+
+#[test]
+fn the_preview_never_shows_on_a_document_switched_to_while_the_picker_is_open() {
+    let mut app = app_at(8);
+    let id = new_type_layer(&mut app, "Hello");
+    pick(&mut app, "#ff0000");
+    assert!(display_doc(&mut app, 0).is_some());
+
+    // A second document becomes the active tab, with a type layer that may share the id.
+    app.run("file.new", json!({"width": 400, "height": 200})).unwrap();
+    let other = new_type_layer(&mut app, "Other");
+    let idx = app.session.active_index().unwrap();
+    assert_ne!(idx, 0);
+    assert!(display_doc(&mut app, idx).is_none(), "the picker belongs to the first document");
+    assert!(display_doc(&mut app, 0).is_none(), "an inactive tab shows no dialog preview");
+    assert_eq!(rgb_at(&app.session.active().unwrap().doc, other, 0), [0, 0, 0, 255]);
+
+    // Back on the document it was opened on, the preview returns.
+    assert!(app.session.set_active(0));
+    let (shown, _) = display_doc(&mut app, 0).expect("preview on its own document");
+    assert_eq!(rgb_at(&shown, id, 0), [255, 0, 0, 255]);
+}
diff --git a/crates/ui-egui/src/dialogs/color_picker_ui.rs b/crates/ui-egui/src/dialogs/color_picker_ui.rs
--- a/crates/ui-egui/src/dialogs/color_picker_ui.rs
+++ b/crates/ui-egui/src/dialogs/color_picker_ui.rs
@@ -182,6 +182,11 @@ pub fn open_for_command(app: &mut PhotocraftApp, label: &str, rgb: [f32; 3], com
     let mut f = fields_with_web(app, "command", label, rgb);
     f.insert("__command".into(), json!(command));
     f.insert("__params".into(), params);
+    // The document the picker was opened on: its live canvas preview never shows on another
+    // (`color_picker_preview`).
+    if let Some(st) = app.session.active() {
+        f.insert("__document".into(), json!(st.doc.id.0));
+    }
     app.ui.open_dialog(DialogKind::Command, f)
 }
 
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
@@ -110,6 +110,7 @@ use dialogs::camera_raw_preview_ui;
 use dialogs::camera_raw_scope_ui;
 pub use dialogs::camera_raw_ui;
 pub use dialogs::channel_options_ui;
+pub(crate) use dialogs::color_picker_preview;
 pub use dialogs::color_picker_ui;
 pub use dialogs::color_range_ui;
 pub use dialogs::color_table_ui;
@@ -582,6 +583,8 @@ pub struct PhotocraftApp {
     pub(crate) type_transform_preview: Option<type_transform::Preview>,
     /// A font hovered in the Type tool's font picker, shown live (`type_font_preview`).
     pub(crate) type_font_preview: Option<type_font_preview::TypeFontPreview>,
+    /// The colour under a text or gradient stop Color Picker, shown live before OK (`color_picker_preview`).
+    pub(crate) color_picker_preview: Option<color_picker_preview::Preview>,
     /// Channel thumbnails for one document snapshot; view-only revisions reuse their pixels.
     channel_thumbs: Option<(DocId, std::sync::Weak<Document>, bool, Vec<egui::TextureHandle>)>,
     /// Channels panel overlays / channel views drawn over the canvas, per document id.
diff --git a/docs/color-picker.md b/docs/color-picker.md
--- a/docs/color-picker.md
+++ b/docs/color-picker.md
@@ -1,6 +1,8 @@
 # Color selection and screen sampling
 
-Click the color swatch in the Type options bar or Character panel to open the full Color Picker. It offers the color field, component strip, HSB/RGB/Lab/CMYK and hex fields, and current/new swatches. Selecting characters targets that range; with no character selection, the active text layer is targeted. **OK** applies through `type.setStyle`; **Cancel** leaves the text and history unchanged. The foreground color is independent of this dialog.
+Click the color swatch in the Type options bar or Character panel to open the full Color Picker. It offers the color field, component strip, HSB/RGB/Lab/CMYK and hex fields, and current/new swatches. Selecting characters targets that range; with no character selection, the active text layer is targeted. While the picker is open, the canvas shows the target in the colour under the cursor, as Photoshop does: the preview runs `type.setStyle` on a copy of the document, so the document, its revision and its history are untouched. **OK** applies through `type.setStyle`, exactly the previewed edit, in one history step; **Cancel** leaves the text and history unchanged. The foreground color is independent of this dialog.
+
+The Color Picker for a gradient fill layer's colour stop (double-click a stop in Properties or on the canvas) previews the same way, through `gradient.fill.stop`. The gradient presets editor's stop picker changes presets, not pixels, and has no canvas preview.
 
 The existing canvas eyedropper continues to sample the document while the Color Picker is open, including canvas zoom and pan. This works without operating-system screen-capture permission.
 
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: tools, handles, modifiers and feel
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (12 of 178 preferences do nothing, from the scorecard; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (the text and gradient stop Color Pickers show the picked colour on the canvas before OK, #5530; 12 of 178 preferences do nothing, from the scorecard; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026
 
 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the
 handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation,
@@ -226,6 +226,7 @@ Paragraph; the rest open as in-app floating windows.
 | Docked resize pushes the neighbour | partial | #2573 |
 | Color panel modes from its panel menu (Hue Cube, Brightness Cube, Color Wheel, Grayscale/RGB/HSB/CMYK/Lab/Web Color sliders), colour ramp (RGB/CMYK Spectrum, Grayscale Ramp, Current Colors, Make Ramp Web Safe; Shift-click cycles, Alt-click picks the other colour), Copy Color as HTML / Hex Code; the same in every theme | done | `color_panel_ui.rs`, `color_panel_tests.rs`; UI-217-17, #2569, #6000 |
 | A colour picked in the Color or Swatches panel or the Color Picker recolours the selection: selected characters while editing; every selected type layer with a Type tool; every selected shape's fill with a shape, Pen or path selection tool; only the foreground with other tools, the Move tool too; a swatch click recolours the selected type layers and shape fills whatever the tool (#9238); one history step (a Color panel drag too) | done: `apply_color.rs`, `apply_color_tests.rs` | |
+| The Type tool's Color Picker (Text Color) and a gradient fill's Color Picker (Stop Color) preview the colour under the cursor on the canvas before OK (the selected characters, every selected type layer, or the stop); Cancel restores the document with no history step, OK commits the previewed edit in one step (#5530) | done: `dialogs/color_picker_preview.rs`, `dialogs/color_picker_preview/tests.rs` | |
 | Window size and dock width remembered | done | UI-217-2 |
 | Histogram panel: Compact (the default, of the entire image), Expanded and All Channels views (the channel kept across them); RGB/CMYK/Lab/Gray channels, Luminosity and Colors (overlaid, mixed where they overlap); Show Channels in Color and Show Statistics; Source (Entire Image, Selected Layer, Adjustment Composite) for multilayer documents; Mean, Std Dev, Median, Pixels, Level, Count, Percentile and Cache Level for the pointer level or a dragged range; the cached-data warning, Uncached Refresh (menu, button, warning click, double-click); the adjusted histogram over the original while an adjustment previews; Cache Levels 1–8 in Preferences › Performance; Color Sampler levels marked on the graph | partial | `histogram_panel.rs`, `histogram_panel_tests.rs`, `tone.rs`; differs: filter dialogs that preview on the CPU proxy aren't counted live, a selection doesn't limit the histogram yet, with Source Selected Layer an adjustment dialog's preview isn't counted, and new strings are English-only so far |
 | Command palette with recents | done | UI-217-7 |
@@ -313,3 +314,4 @@ Paragraph; the rest open as in-app floating windows.
 | 2026-10-14 | minor | ⇧⌥ + letter blend mode keys for the painting tool or every selected layer; ⇧+ / ⇧− now step every selected layer and include Clear for Brush and Pencil |
 | 2026-10-15 | minor | ⇧⌥ blend mode letters work while a Free Transform box is open, without picking a tool |
 | 2026-10-14 | minor | Mode dropdowns for Clone Stamp, Pattern Stamp, History Brush, Healing and Paint Bucket; the blend mode keys reach them, and tools without a blend mode (not Dodge, Burn or Sponge) pass the keys to the layers |
+| 2026-10-26 | minor | The text and gradient stop Color Pickers preview the picked colour on the canvas before OK, as Photoshop's do (#5530); no percentage changed |
