diff --git a/crates/engine/src/mode_cmds.rs b/crates/engine/src/mode_cmds.rs
--- a/crates/engine/src/mode_cmds.rs
+++ b/crates/engine/src/mode_cmds.rs
@@ -9,6 +9,8 @@
 
 use photocraft_algo::quantize::{self, BitmapMethod, Dither, Forced, HalftoneShape, PaletteKind};
 use photocraft_algo::transform::{Homography, Interp};
+use photocraft_cms::{Builtin, TransformOptions};
+
 use photocraft_color::{ColorMode, PixelFormat, SampleType};
 use photocraft_doc::adjust::CurvePoint;
 use photocraft_doc::{ColorTable, Document, Duotone, DuotoneInk, Layer, LayerContent, LayerId, Size};
@@ -169,6 +171,32 @@ fn composite(doc: &Document, opaque: bool) -> Vec<[f32; 4]> {
     if opaque { buf.over_background([1.0, 1.0, 1.0]).px } else { buf.px }
 }
 
+/// Indexed storage and its palette are sRGB. Convert RGB/gray composites before quantisation so
+/// wide-gamut document samples are not interpreted as sRGB numbers by the quantizer.
+fn composite_in_srgb(doc: &Document, opaque: bool) -> Result<Vec<[f32; 4]>> {
+    const CMD: &str = "image.mode.indexedColor";
+    let mut pixels = composite(doc, opaque);
+    let channels = match doc.mode {
+        ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone => 1,
+        ColorMode::Rgb | ColorMode::Indexed => 3,
+        _ => return Ok(pixels),
+    };
+    let source = crate::color_cmds::document_profile(doc);
+    let srgb = Builtin::Srgb.profile();
+    if source.same_colors(srgb) {
+        return Ok(pixels);
+    }
+    let transform =
+        photocraft_cms::cached(&source, srgb, TransformOptions::default()).map_err(|e| bad(CMD, format!("could not convert the composite to sRGB: {e}")))?;
+    let mut converted = [0.0f32; 16];
+    for pixel in &mut pixels {
+        let input = if channels == 1 { &pixel[..1] } else { &pixel[..3] };
+        transform.eval(input, &mut converted);
+        pixel[..3].copy_from_slice(&converted[..3]);
+    }
+    Ok(pixels)
+}
+
 /// Replace the document's layers by one 8-bit layer holding `px` in `mode`'s storage format.
 fn single_layer(doc: &mut Document, active: &mut Option<LayerId>, mode: ColorMode, px: &[[f32; 4]], name: &str, background: bool) {
     doc.mode = mode;
@@ -242,7 +270,7 @@ fn indexed_color(s: &mut Session, p: &Value) -> Result<Value> {
     let amount = num(p, "amount", 75.0).clamp(0.0, 100.0) / 100.0;
     let transparency = p.get("transparency").and_then(Value::as_bool).unwrap_or(true);
     let (pal_len, transparent) = s.edit("Indexed Color", |doc, active| {
-        let mut px = composite(doc, !transparency);
+        let mut px = composite_in_srgb(doc, !transparency)?;
         let has_clear = transparency && px.iter().any(|q| q[3] < 0.5);
         let mut pal = quantize::build_palette(&px, kind, if has_clear { colors.saturating_sub(1).max(2) } else { colors }, forced).map_err(|e| bad(CMD, e))?;
         let transparent = if has_clear && pal.len() < 256 {
@@ -254,6 +282,7 @@ fn indexed_color(s: &mut Session, p: &Value) -> Result<Value> {
         let w = doc.size.width as usize;
         quantize::quantize(&mut px, w, &pal, dither, amount, transparent);
         single_layer(doc, active, ColorMode::Indexed, &px, if transparent.is_some() { "Index" } else { "Background" }, transparent.is_none());
+        doc.icc_profile = Some(Builtin::Srgb.profile().to_bytes());
         doc.color_table = Some(ColorTable { colors: pal.clone(), transparent: transparent.map(|t| t as u8) });
         Ok((pal.len(), transparent))
     })?;
diff --git a/crates/engine/src/mode_cmds/tests.rs b/crates/engine/src/mode_cmds/tests.rs
--- a/crates/engine/src/mode_cmds/tests.rs
+++ b/crates/engine/src/mode_cmds/tests.rs
@@ -194,6 +194,57 @@ fn indexed_color_palettes_and_dithers() {
     }
 }
 
+#[test]
+fn indexed_wide_gamut_composites_in_srgb_before_quantizing() {
+    for (profile_id, source) in [("display-p3", photocraft_cms::Builtin::DisplayP3), ("adobe-rgb-compat", photocraft_cms::Builtin::AdobeRgbCompat)] {
+        for depth in [8, 16, 32] {
+            let mut s = Session::new();
+            s.execute("file.new", json!({"width": 2, "height": 1, "depth": depth, "mode": "rgb", "background": "transparent"})).unwrap();
+            paint(&mut s, |x, _| if x == 0 { [0.13, 0.41, 0.79, 1.0] } else { [0.71, 0.27, 0.19, 0.6] });
+            s.execute("edit.assignProfile", json!({"profile": profile_id})).unwrap();
+            let source_bytes = source.profile().to_bytes();
+            let source_profile = photocraft_cms::Profile::parse(&source_bytes).unwrap();
+            let to_srgb =
+                photocraft_cms::cached(&source_profile, photocraft_cms::Builtin::Srgb.profile(), photocraft_cms::TransformOptions::default()).unwrap();
+            let expected = |rgb: [f32; 3]| {
+                let mut out = [0.0f32; 16];
+                to_srgb.eval(&rgb, &mut out);
+                [0, 1, 2].map(|i| (out[i].clamp(0.0, 1.0) * 255.0).round() as u8)
+            };
+            let surface = doc(&s).layers[0].surface().unwrap();
+            let stored_a = surface.rgba(0, 0);
+            let stored_b = surface.rgba(1, 0);
+            let expected_a = expected([stored_a[0], stored_a[1], stored_a[2]]);
+            let expected_b = expected([stored_b[0], stored_b[1], stored_b[2]]);
+            assert_ne!(expected_a, [33, 105, 201], "{profile_id}: the regression colours must move");
+            assert_ne!(expected_b, [181, 69, 48], "{profile_id}: the regression colours must move");
+
+            s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "dither": "none"})).unwrap();
+            let d = doc(&s);
+            assert_eq!((d.mode, d.depth), (ColorMode::Indexed, SampleType::U8), "{profile_id} {depth}");
+            assert_eq!(d.icc_profile.as_deref(), Some(&*photocraft_cms::Builtin::Srgb.profile().to_bytes()), "{profile_id} {depth}");
+            let table = d.color_table.as_ref().unwrap();
+            assert!(table.colors.contains(&expected_a), "{profile_id} {depth}: {expected_a:?} not in {:?}", table.colors);
+            assert!(table.colors.contains(&expected_b), "{profile_id} {depth}: {expected_b:?} not in {:?}", table.colors);
+            let surface = d.layers[0].surface().unwrap();
+            for (x, (want, alpha)) in [(0, (expected_a, 1.0)), (1, (expected_b, 0.6))] {
+                let got = surface.rgba(x, 0);
+                let got8 = [0, 1, 2].map(|i| (got[i].clamp(0.0, 1.0) * 255.0).round() as u8);
+                assert_eq!(got8, want, "{profile_id} {depth} x={x}");
+                assert!((got[3] - alpha).abs() <= 1.0 / 255.0, "{profile_id} {depth} x={x}: {got:?}");
+            }
+
+            s.execute("edit.undo", json!({})).unwrap();
+            assert_eq!(doc(&s).mode, ColorMode::Rgb);
+            assert_eq!(doc(&s).icc_profile.as_deref(), Some(&*source_bytes));
+            s.execute("edit.redo", json!({})).unwrap();
+            assert_eq!(doc(&s).icc_profile.as_deref(), Some(&*photocraft_cms::Builtin::Srgb.profile().to_bytes()));
+            assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(0, 0)[3], 1.0);
+            assert!((doc(&s).layers[0].surface().unwrap().rgba(1, 0)[3] - 0.6).abs() <= 1.0 / 255.0);
+        }
+    }
+}
+
 #[test]
 fn indexed_exact_transparency_and_sources() {
     // Two colours + transparency: exact palette with a transparent entry.
diff --git a/docs/development.md b/docs/development.md
--- a/docs/development.md
+++ b/docs/development.md
@@ -482,6 +482,11 @@ How the web shell (`apps/photocraft-web/src/web.rs`) differs from desktop:
 
 ## Indexed Color timings
 
+Indexed Color converts the composited image from its composite ICC profile to encoded sRGB
+before palette construction and quantization, preserving alpha. The resulting expanded RGB
+samples and color table are tagged sRGB. Untagged/sRGB input retains the identity fast path;
+the stock dialog and quantizer are unchanged.
+
 `cargo run --release -p photocraft-engine --example indexed_color_perf -- image.png` measures
 palette construction, Floyd–Steinberg diffusion, and the complete full-resolution CPU preview
 path (proxy copy, engine command, result composition; excludes GPU upload/presentation).
