diff --git a/crates/engine/src/cmd/mode_cmds.rs b/crates/engine/src/cmd/mode_cmds.rs
--- a/crates/engine/src/cmd/mode_cmds.rs
+++ b/crates/engine/src/cmd/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};
@@ -189,6 +191,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;
@@ -243,6 +271,32 @@ fn parse_dither(s: &str) -> Option<Dither> {
     })
 }
 
+fn indexed_matte(s: &Session, p: &Value) -> Result<Option<[f32; 3]>> {
+    const CMD: &str = "image.mode.indexedColor";
+    let rgba = |color: [f32; 4]| [color[0], color[1], color[2]];
+    let matte = match p.get("matte") {
+        None | Some(Value::Null) => return Ok(None),
+        Some(Value::String(value)) => match value.as_str() {
+            "none" => return Ok(None),
+            "white" => [1.0; 3],
+            "black" => [0.0; 3],
+            "foreground" => rgba(s.tools.foreground),
+            "background" => rgba(s.tools.background),
+            value if value.len() == 7 && value.starts_with('#') => p
+                .get("matte")
+                .and_then(parse_color)
+                .ok_or_else(|| bad(CMD, "matte must be none, foreground, background, white, black or #rrggbb"))?
+                .map(|v| f32::from(v) / 255.0),
+            _ => return Err(bad(CMD, "matte must be none, foreground, background, white, black or #rrggbb")),
+        },
+        _ => return Err(bad(CMD, "matte must be a colour string")),
+    };
+    if matte.iter().any(|v| !v.is_finite() || !(0.0..=1.0).contains(v)) {
+        return Err(bad(CMD, "matte channels must be finite and in 0..=1"));
+    }
+    Ok(Some(matte))
+}
+
 fn indexed_color(s: &mut Session, p: &Value) -> Result<Value> {
     const CMD: &str = "image.mode.indexedColor";
     let kind = PaletteKind::from_id(str_or(p, "palette", "selective"))
@@ -261,8 +315,23 @@ 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 matte = indexed_matte(s, p)?;
     let (pal_len, transparent) = s.edit("Indexed Color", |doc, active| {
-        let mut px = composite(doc, !transparency);
+        let mut px = composite_in_srgb(doc, !transparency && matte.is_none())?;
+        if let Some(matte) = matte {
+            for pixel in &mut px {
+                let alpha = pixel[3].clamp(0.0, 1.0);
+                if alpha == 1.0 {
+                    continue;
+                }
+                if !transparency || alpha > 0.0 {
+                    for (channel, background) in pixel[..3].iter_mut().zip(matte) {
+                        *channel = *channel * alpha + background * (1.0 - alpha);
+                    }
+                    pixel[3] = 1.0;
+                }
+            }
+        }
         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 {
@@ -274,6 +343,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))
     })?;
@@ -606,7 +676,7 @@ pub fn specs() -> Vec<CommandSpec> {
             "image.mode.indexedColor",
             "Indexed Color…",
             ["Image", "Mode"],
-            r##"{"palette":"selective|perceptual|adaptive|exact|systemMac|systemWindows|web|uniform"="selective","colors":2..256=256,"forced":"blackWhite|none|primaries|web"="blackWhite","transparency":bool=true,"dither":"diffusion|none|pattern|noise"="diffusion","amount":0..100=75} (flattens)"##,
+            r##"{"palette":"selective|perceptual|adaptive|exact|systemMac|systemWindows|web|uniform"="selective","colors":2..256=256,"forced":"blackWhite|none|primaries|web"="blackWhite","transparency":bool=true,"matte":color,"dither":"diffusion|none|pattern|noise"="diffusion","amount":0..100=75} (flattens; matte: none|foreground|background|white|black|#rrggbb)"##,
             |s| mode_is(
                 s,
                 &[ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Indexed, ColorMode::Duotone, ColorMode::Cmyk, ColorMode::Lab],
diff --git a/crates/engine/src/cmd/mode_cmds/tests.rs b/crates/engine/src/cmd/mode_cmds/tests.rs
--- a/crates/engine/src/cmd/mode_cmds/tests.rs
+++ b/crates/engine/src/cmd/mode_cmds/tests.rs
@@ -247,6 +247,112 @@ 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_matte_blends_edges_without_replacing_clear_pixels() {
+    for depth in [8, 16, 32] {
+        for (matte, color) in [
+            ("black", [0.0, 0.0, 0.0]),
+            ("white", [1.0, 1.0, 1.0]),
+            ("#0000ff", [0.0, 0.0, 1.0]),
+            ("foreground", [0.0, 0.0, 1.0]),
+            ("background", [0.0, 1.0, 0.0]),
+        ] {
+            let mut s = Session::new();
+            s.execute("file.new", json!({"width": 4, "height": 1, "depth": depth, "background": "transparent"})).unwrap();
+            s.execute("tools.setColors", json!({"foreground": "#0000ff", "background": "#00ff00"})).unwrap();
+            paint(&mut s, |x, _| match x {
+                0 => [0.0, 1.0, 0.0, 1.0],
+                1 => [1.0, 0.0, 0.0, 0.75],
+                2 => [1.0, 0.0, 0.0, 0.25],
+                _ => [0.0; 4],
+            });
+            let source = [1, 2].map(|x| doc(&s).layers[0].surface().unwrap().rgba(x, 0));
+            let expected = source.map(|pixel| [0, 1, 2].map(|i| pixel[i] * pixel[3] + color[i] * (1.0 - pixel[3])));
+            s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "dither": "none", "matte": matte})).unwrap();
+            let surface = doc(&s).layers[0].surface().unwrap();
+            let edges = [1, 2].map(|x| surface.rgba(x, 0));
+            for (edge, expected) in edges.iter().zip(expected) {
+                for i in 0..3 {
+                    assert!((edge[i] - expected[i]).abs() <= 1.0 / 255.0, "{depth}-bit {matte}: {edge:?} vs {expected:?}");
+                }
+                assert_eq!(edge[3], 1.0);
+            }
+            assert_eq!(surface.rgba(3, 0)[3], 0.0, "{matte}: fully transparent pixels stay clear");
+            assert_eq!(surface.rgba(0, 0), [0.0, 1.0, 0.0, 1.0]);
+            s.execute("edit.undo", json!({})).unwrap();
+            assert_eq!([1, 2].map(|x| doc(&s).layers[0].surface().unwrap().rgba(x, 0)), source);
+            s.execute("edit.redo", json!({})).unwrap();
+            assert_eq!([1, 2].map(|x| doc(&s).layers[0].surface().unwrap().rgba(x, 0)), edges);
+        }
+    }
+}
+
+#[test]
+fn indexed_matte_without_transparency_fills_clear_pixels_and_rejects_bad_input_atomically() {
+    let mut s = Session::new();
+    s.execute("file.new", json!({"width": 2, "height": 1, "background": "transparent"})).unwrap();
+    let before = s.active().unwrap().revision;
+    for matte in [json!(false), json!(12), json!("#gg0000"), json!("#000000ff"), json!("unknown")] {
+        assert!(s.execute("image.mode.indexedColor", json!({"matte": matte})).is_err(), "{matte}");
+        assert_eq!(s.active().unwrap().revision, before);
+        assert_eq!(doc(&s).mode, ColorMode::Rgb);
+    }
+    s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "dither": "none", "transparency": false, "matte": "#0000ff"})).unwrap();
+    assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(0, 0), [0.0, 0.0, 1.0, 1.0]);
+    assert_eq!(doc(&s).color_table.as_ref().unwrap().transparent, None);
+}
+
 #[test]
 fn indexed_exact_transparency_and_sources() {
     // Two colours + transparency: exact palette with a transparent entry.
diff --git a/crates/ui-egui/src/dialogs/filter_dialog.rs b/crates/ui-egui/src/dialogs/filter_dialog.rs
--- a/crates/ui-egui/src/dialogs/filter_dialog.rs
+++ b/crates/ui-egui/src/dialogs/filter_dialog.rs
@@ -776,6 +776,44 @@ fn replace_color_result(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
     }
 }
 
+fn indexed_matte_row(ui: &mut egui::Ui, fields: &mut Map<String, Value>) {
+    let value = fields.get("matte").and_then(Value::as_str).unwrap_or("none");
+    let mut choice = if value.starts_with('#') { "custom" } else { value };
+    let color = crate::color_picker_ui::parse_hex(value).unwrap_or([1.0; 3]);
+    let mut changed = false;
+    let mut pick = false;
+    ui.horizontal(|ui| {
+        ui.label(egui::RichText::new(tl!("Matte")).color(Tokens::get(ui.ctx()).text_dim));
+        changed = crate::widgets::dropdown(
+            ui,
+            "indexed-matte",
+            "Matte",
+            &mut choice,
+            &[
+                ("none", "None"),
+                ("foreground", "Foreground Color"),
+                ("background", "Background Color"),
+                ("white", "White"),
+                ("black", "Black"),
+                ("custom", "Other…"),
+            ],
+            170.0,
+        );
+        if choice == "custom" {
+            let bytes = color.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8);
+            pick = crate::widgets::color_swatch_button(ui, egui::Color32::from_rgb(bytes[0], bytes[1], bytes[2]), tl!("Matte")).clicked();
+        }
+    });
+    let custom = choice == "custom";
+    if changed {
+        let next = if custom { crate::color_picker_ui::hex(color) } else { choice.to_owned() };
+        fields.insert("matte".into(), json!(next));
+    }
+    if custom && (changed || pick) {
+        crate::color_picker_ui::request_field(fields, "matte", &crate::color_picker_ui::title_for("Matte"));
+    }
+}
+
 /// Dialog body for filter commands.
 pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
     let t = Tokens::get(ui.ctx());
@@ -792,6 +830,10 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
         crate::select_mask_view::controls(ui, f);
     }
     for p in parse_spec(&spec) {
+        if cmd == "image.mode.indexedColor" && p.key == "matte" {
+            indexed_matte_row(ui, f);
+            continue;
+        }
         if zoom_center::owns(&cmd, f) && matches!(p.key.as_str(), "centerX" | "centerY") {
             if p.key == "centerX" {
                 zoom_center::row(ui, f);
diff --git a/crates/ui-egui/src/dialogs/filter_dialog/tests.rs b/crates/ui-egui/src/dialogs/filter_dialog/tests.rs
--- a/crates/ui-egui/src/dialogs/filter_dialog/tests.rs
+++ b/crates/ui-egui/src/dialogs/filter_dialog/tests.rs
@@ -1546,3 +1546,47 @@ fn replace_color_localized_color_clusters() {
     assert!(px(2)[1] > 0.5, "near the point: shifted to green {:?}", px(2));
     assert!(px(38)[0] > 0.9 && px(38)[1] < 0.1, "far away: still red {:?}", px(38));
 }
+
+#[test]
+fn indexed_matte_dropdown_and_picker_change_the_applied_edge_color() {
+    use egui::accesskit::Role;
+    use egui_kittest::{Harness, kittest::Queryable};
+
+    for (choice, blue) in [("Black", 0.0), ("Other…", 0.25)] {
+        let mut session = photocraft_engine::Session::new();
+        session.execute("file.new", json!({"width": 2, "height": 1, "depth": 32, "background": "transparent"})).unwrap();
+        session
+            .edit("fixture", |doc, active| {
+                let bounds = doc.bounds();
+                doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().write_region(bounds, &[1.0, 0.0, 0.0, 0.75, 0.0, 0.0, 0.0, 0.0]);
+                Ok(())
+            })
+            .unwrap();
+        let mut app = PhotocraftApp::new(session, crate::Services::default());
+        let mut harness = Harness::builder().with_size(egui::vec2(900.0, 1000.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), &mut app);
+        PhotocraftApp::setup_context(&harness.ctx, crate::theme::ThemeKind::ALL[0]);
+        let id = open(harness.state_mut(), "image.mode.indexedColor").unwrap();
+        harness.state_mut().ui.dialog_mut(id).unwrap().fields.insert("palette".into(), json!("exact"));
+        harness.run_steps(3);
+        harness.query_all_by_role(Role::ComboBox).find(|node| node.value().as_deref() == Some("None")).expect("Matte dropdown").click();
+        harness.run_steps(2);
+        harness.get_by_label(choice).click();
+        harness.run_steps(3);
+        if choice == "Other…" {
+            let picker = harness.state().ui.dialogs.last().unwrap().id;
+            assert_ne!(picker, id);
+            harness.state_mut().ui.dialog_mut(picker).unwrap().fields.insert("color".into(), json!("#0000ff"));
+            crate::dialogs::confirm(harness.state_mut(), picker).unwrap();
+            harness.state_mut().ui.dialogs.retain(|dialog| dialog.id != picker);
+            harness.run_steps(3);
+        }
+        harness.get_by_label("OK").click();
+        harness.run_steps(3);
+        let doc = &harness.state().session.active().unwrap().doc;
+        assert_eq!(doc.mode, photocraft_color::ColorMode::Indexed);
+        let surface = doc.layers[0].surface().unwrap();
+        let edge = surface.rgba(0, 0);
+        assert!((edge[0] - 0.75).abs() <= 1.0 / 255.0 && edge[1] == 0.0 && (edge[2] - blue).abs() <= 1.0 / 255.0 && edge[3] == 1.0, "{choice}: {edge:?}");
+        assert_eq!(surface.rgba(1, 0)[3], 0.0);
+    }
+}
diff --git a/docs/development.md b/docs/development.md
--- a/docs/development.md
+++ b/docs/development.md
@@ -523,6 +523,19 @@ 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 quantizer retains its stock path.
+
+The stock Indexed Color dialog exposes Matte: None, Foreground Color, Background Color,
+White, Black and Other… (the shared Color Picker). Automation accepts `matte` as `none`,
+`foreground`, `background`, `white`, `black` or `#rrggbb`. A selected matte blends all
+partially transparent edges; with Transparency on, fully clear pixels stay transparent.
+With Transparency off, the selected matte also fills clear pixels. None retains the existing
+hard-edged transparency/default flattening behavior. This follows Adobe's documented
+[Indexed Color conversion options](https://helpx.adobe.com/photoshop/desktop/adjust-color/color-modes/conversion-options-for-indexed-color-images.html).
+
 `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).
