diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs
--- a/apps/lightcraft/src/native_menu.rs
+++ b/apps/lightcraft/src/native_menu.rs
@@ -489,9 +489,7 @@ impl NativeMenu {
             // events never reach `shortcuts::handle`, so this is the one guard for AppKit's
             // accelerators and clicks: while the blocking library window or a quit/settings notice
             // owns input, a queued command is dropped (Quit stays for the existing confirmation).
-            let (cmd, params) = if key == QUIT {
-                (key, serde_json::json!({}))
-            } else if key == "app.about" || key == SETTINGS {
+            let (cmd, params) = if key == QUIT || key == "app.about" || key == SETTINGS {
                 (key, serde_json::json!({}))
             } else if let Some(it) = self.items.get(&key) {
                 (it.cmd.clone(), it.params.clone())
diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs
--- a/crates/engine/src/export.rs
+++ b/crates/engine/src/export.rs
@@ -31,7 +31,7 @@ pub use encoding::{
 };
 pub use options::{
     Conflict, DestinationMode, ExportFormat, ExportPreset, HdrCurve, MAX_ENLARGED_PIXELS, MetadataPolicy, Resize, ResizeMode, SharpenAmount,
-    SharpenFor, builtin_presets,
+    SharpenFor, TIGHT_LIMIT_BITS_PER_PIXEL, builtin_presets, size_limit_is_tight,
 };
 pub use params::{
     OPTION_PARAMS, TARGET_PARAMS, WATERMARK_IMAGE_WIDTH_RANGE, WATERMARK_INSET_RANGE, WATERMARK_PARAMS, WATERMARK_SIZE_RANGE,
diff --git a/crates/engine/src/export/encoding.rs b/crates/engine/src/export/encoding.rs
--- a/crates/engine/src/export/encoding.rs
+++ b/crates/engine/src/export/encoding.rs
@@ -88,6 +88,11 @@ pub fn encode_image(img: &Rgba8, o: &ExportOptions) -> Result<Vec<u8>, String> {
 
 /// Like [`encode_image`], embedding `meta` (already filtered by the policy) as EXIF + XMP.
 pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metadata>) -> Result<Vec<u8>, String> {
+    encode_8bit(img, o, meta).map(|(bytes, _)| bytes)
+}
+
+/// [`encode_with_metadata`], with the JPEG quality a file-size limit settled on (None without one).
+fn encode_8bit(img: &Rgba8, o: &ExportOptions, meta: Option<&Metadata>) -> Result<(Vec<u8>, Option<u8>), String> {
     let img = finish_8bit(img, o);
     let space = o.effective_space();
     let profile = icc::write_named(named_space(space));
@@ -107,7 +112,8 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
         ExportFormat::Jpeg => {
             // 4:4:4 normally; 4:2:0 when targeting a file size (much smaller at equal visual quality).
             let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 };
-            return encode_jpeg_with_limit(o, |q| encode::encode_jpeg(&e, q, sub, &meta));
+            let (bytes, q) = encode_jpeg_with_limit(o, |q| encode::encode_jpeg(&e, q, sub, &meta))?;
+            return Ok((bytes, o.limit_kb.map(|_| q)));
         }
         ExportFormat::Png => encode::encode_png(&e, &meta),
         ExportFormat::Tiff => encode::encode_tiff(&e, o.tiff_compression, &meta),
@@ -118,12 +124,13 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
         f @ (ExportFormat::Original | ExportFormat::Dng) => return Err(format!("{f:?} export does not encode pixels")),
         ExportFormat::RenderedDng => return Err("Render to DNG needs a float render".into()),
     };
-    r.map_err(|e| e.to_string())
+    r.map(|bytes| (bytes, None)).map_err(|e| e.to_string())
 }
 
-/// Apply the same file-size policy to ordinary JPEGs and complete HDR gain map JPEGs.
-fn encode_jpeg_with_limit(o: &ExportOptions, jpeg: impl Fn(u8) -> lightcraft_codecs::Result<Vec<u8>>) -> Result<Vec<u8>, String> {
-    let Some(kb) = o.limit_kb else { return jpeg(o.quality).map_err(|e| e.to_string()) };
+/// Apply the same file-size policy to ordinary JPEGs and complete HDR gain map JPEGs: the file and
+/// the quality it was written at (`o.quality`, or the highest up to it that fits `o.limit_kb`).
+fn encode_jpeg_with_limit(o: &ExportOptions, jpeg: impl Fn(u8) -> lightcraft_codecs::Result<Vec<u8>>) -> Result<(Vec<u8>, u8), String> {
+    let Some(kb) = o.limit_kb else { return jpeg(o.quality).map(|b| (b, o.quality)).map_err(|e| e.to_string()) };
     let limit = u64::from(kb) * 1024;
     let (mut lo, mut hi) = (1u8, o.quality.min(100));
     let mut best = None;
@@ -131,7 +138,7 @@ fn encode_jpeg_with_limit(o: &ExportOptions, jpeg: impl Fn(u8) -> lightcraft_cod
         let mid = lo + (hi - lo) / 2;
         let b = jpeg(mid).map_err(|e| e.to_string())?;
         if b.len() as u64 <= limit {
-            best = Some(b);
+            best = Some((b, mid));
             lo = mid + 1;
         } else if mid == 1 {
             break;
@@ -169,6 +176,11 @@ fn finish_deep(img: &DeepImage, o: &ExportOptions) -> DeepImage {
 /// and the HDR rendition `hdr` (float linear, same space and size). The gain map is measured
 /// against the base as written, so its 8-bit rounding is handed back by the map.
 pub fn encode_gain_map_jpeg(sdr: &Rgba8, hdr: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result<Vec<u8>, String> {
+    gain_map_jpeg(sdr, hdr, o, meta).map(|(bytes, _)| bytes)
+}
+
+/// [`encode_gain_map_jpeg`], with the quality it was written at (see [`encode_jpeg_with_limit`]).
+pub(super) fn gain_map_jpeg(sdr: &Rgba8, hdr: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result<(Vec<u8>, u8), String> {
     use lightcraft_codecs::gainmap;
     let base = finish_8bit(sdr, o);
     let hdr = finish_deep(hdr, o);
@@ -220,9 +232,14 @@ pub fn srgb8_in(space: OutputSpace, c: [u8; 3]) -> [u8; 3] {
 /// Encode a render according to `o`: its high-bit-depth samples when it has them (16-bit PNG/TIFF,
 /// 10-bit AVIF, 32-bit float linear TIFF), else its 8-bit image.
 pub fn encode_rendered(r: &lightcraft_pipeline::Rendered, o: &ExportOptions, meta: Option<&Metadata>) -> Result<Vec<u8>, String> {
+    rendered(r, o, meta).map(|(bytes, _)| bytes)
+}
+
+/// [`encode_rendered`], with the JPEG quality a file-size limit settled on (None without one).
+pub(super) fn rendered(r: &lightcraft_pipeline::Rendered, o: &ExportOptions, meta: Option<&Metadata>) -> Result<(Vec<u8>, Option<u8>), String> {
     match &r.deep {
-        Some(d) if o.effective_depth() != OutputDepth::U8 => encode_deep(d, o, meta),
-        _ => encode_with_metadata(&r.image, o, meta),
+        Some(d) if o.effective_depth() != OutputDepth::U8 => encode_deep(d, o, meta).map(|bytes| (bytes, None)),
+        _ => encode_8bit(&r.image, o, meta),
     }
 }
 
diff --git a/crates/engine/src/export/options.rs b/crates/engine/src/export/options.rs
--- a/crates/engine/src/export/options.rs
+++ b/crates/engine/src/export/options.rs
@@ -105,6 +105,24 @@ impl Default for Resize {
 /// Lightroom's ceiling. A full-size or smaller export is never held to it.
 pub const MAX_ENLARGED_PIXELS: f64 = 512e6;
 
+/// Below this many bits per pixel a JPEG file-size limit can force a quality low enough to break
+/// smooth areas such as skies into flat blocks: baseline JPEG degrades sharply between about 0.5
+/// and 0.25 bits per pixel, where each 8×8 block keeps little more than its average. 1 MB is about
+/// 0.2 over a 40 MP photo, where a detailed X-T5 frame fit at quality 12 (issue #3033), and about 3
+/// over 2048 px on the long edge; a smooth demo scene still fits at quality 87 with 0.6.
+///
+/// The Export dialog uses this to *estimate*, before anything is encoded, whether a limit is tight
+/// ([`size_limit_is_tight`]); the export toast *reports* the quality the limit actually settled on
+/// and has its own threshold for when that is low (`LOW_LIMIT_QUALITY` in the UI's `export_task`).
+/// Tune them together: a limit the dialog calls tight should usually toast a low quality.
+pub const TIGHT_LIMIT_BITS_PER_PIXEL: f64 = 0.5;
+
+/// Whether `limit_kb` leaves an output of `pixels` fewer than [`TIGHT_LIMIT_BITS_PER_PIXEL`].
+pub fn size_limit_is_tight(limit_kb: u32, pixels: usize) -> bool {
+    let px = pixels as f64;
+    px > 0.0 && f64::from(limit_kb) * 8192.0 / px < TIGHT_LIMIT_BITS_PER_PIXEL
+}
+
 impl Resize {
     pub fn long_edge(px: u32) -> Self {
         Self { value: px as f32, ..Default::default() }
diff --git a/crates/engine/src/export/place.rs b/crates/engine/src/export/place.rs
--- a/crates/engine/src/export/place.rs
+++ b/crates/engine/src/export/place.rs
@@ -164,6 +164,9 @@ impl<'a> Placer<'a> {
                 if let Some(why) = &e.gpu_fallback {
                     entry["gpuFallback"] = json!(why);
                 }
+                if let Some(q) = e.limit_quality {
+                    entry["quality"] = json!(q);
+                }
                 self.out.push(entry);
             }
             Err(err) if self.stop_on_error => return Err(err),
diff --git a/crates/engine/src/export/prepare.rs b/crates/engine/src/export/prepare.rs
--- a/crates/engine/src/export/prepare.rs
+++ b/crates/engine/src/export/prepare.rs
@@ -1,5 +1,5 @@
 use super::batch::run_batch;
-use super::encoding::{encode_gain_map_jpeg, encode_rendered, export_metadata, removes_location};
+use super::encoding::{export_metadata, gain_map_jpeg, removes_location, rendered};
 use super::options::ExportFormat;
 use super::params::apple_photos_unsupported;
 use super::*;
@@ -16,6 +16,9 @@ pub struct Exported {
     /// Set when the render was tried on the GPU and fell back to the CPU: why (see
     /// `docs/gpu-pipeline.md`). The file holds the CPU render either way.
     pub gpu_fallback: Option<String>,
+    /// The JPEG quality a file-size limit (`limitKb`) settled on: the highest up to the requested
+    /// quality whose file fits. None without a limit.
+    pub limit_quality: Option<u8>,
 }
 
 /// Output size of photo `p` under `o` (its cropped full size when `o.resize` is `None`).
@@ -207,15 +210,16 @@ impl PreparedExport {
                 let result = job.run();
                 let r = result.rendered?;
                 let gpu_fallback = result.gpu_fallback;
-                let bytes = match hdr_job {
+                let (bytes, limit_quality) = match hdr_job {
                     Some(hj) => {
                         let hr = hj.run().rendered?;
                         let hdr = hr.deep.ok_or("the HDR render has no float samples")?;
-                        encode_gain_map_jpeg(&r.image, &hdr, &opts, meta.as_ref())?
+                        let (bytes, q) = gain_map_jpeg(&r.image, &hdr, &opts, meta.as_ref())?;
+                        (bytes, opts.limit_kb.map(|_| q))
                     }
-                    None => encode_rendered(&r, &opts, meta.as_ref())?,
+                    None => rendered(&r, &opts, meta.as_ref())?,
                 };
-                Ok(Exported { file_name, bytes, width: r.image.width, height: r.image.height, sidecars: Vec::new(), gpu_fallback })
+                Ok(Exported { file_name, bytes, width: r.image.width, height: r.image.height, sidecars: Vec::new(), gpu_fallback, limit_quality })
             }
             Work::File { path, read, packet, dng, quality, lossy_edge, label, size, remove_location } => {
                 let bytes = match &read {
@@ -240,6 +244,7 @@ impl PreparedExport {
                         height: size.1,
                         sidecars: vec![("xmp", packet.into_bytes())],
                         gpu_fallback: None,
+                        limit_quality: None,
                     });
                 };
                 if lightcraft_raw::probe(&bytes).is_none() {
@@ -258,7 +263,15 @@ impl PreparedExport {
                 };
                 let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: Some(packet), compression, ..Default::default() })
                     .map_err(|e| e.to_string())?;
-                Ok(Exported { file_name, bytes: dng, width: raw.width, height: raw.height, sidecars: Vec::new(), gpu_fallback: None })
+                Ok(Exported {
+                    file_name,
+                    bytes: dng,
+                    width: raw.width,
+                    height: raw.height,
+                    sidecars: Vec::new(),
+                    gpu_fallback: None,
+                    limit_quality: None,
+                })
             }
         }
     }
diff --git a/crates/engine/src/export/tests.rs b/crates/engine/src/export/tests.rs
--- a/crates/engine/src/export/tests.rs
+++ b/crates/engine/src/export/tests.rs
@@ -67,6 +67,31 @@ fn size_limit_respected() {
     assert!(b.len() <= kb as usize * 1024);
 }
 
+// Issue #3033: a size limit can cut the quality far below the requested one, so each file says
+// what it settled on; without a limit the file is written at the requested quality and says nothing
+#[test]
+fn a_size_limit_reports_the_quality_it_settled_on() {
+    let mut s = crate::Session::with_demo();
+    let ids: Vec<_> = s.visible().iter().copied().take(1).collect();
+    let limited = ExportOptions::from_json(&json!({"format": "jpeg", "quality": 90, "limitKb": 8, "width": 512}));
+    let (files, _, _) = lanes_batch(&mut s, &ids, &limited, 1, true, usize::MAX);
+    let files = files.unwrap();
+    let q = files[0]["quality"].as_u64().unwrap_or_else(|| panic!("no quality: {files:?}"));
+    assert!((1..90).contains(&q), "{files:?}");
+    assert!(files[0]["bytes"].as_u64().unwrap() <= 8 * 1024, "{files:?}");
+    let plain = ExportOptions::from_json(&json!({"format": "jpeg", "quality": 90, "width": 512}));
+    let (files, _, _) = lanes_batch(&mut s, &ids, &plain, 1, true, usize::MAX);
+    assert!(files.unwrap()[0].get("quality").is_none());
+}
+
+#[test]
+fn a_size_limit_is_tight_below_half_a_bit_per_pixel() {
+    assert!(size_limit_is_tight(1024, 5152 * 7728), "1 MB over 40 MP is about 0.2 bits per pixel");
+    assert!(!size_limit_is_tight(1024, 1365 * 2048), "about 3 bits per pixel");
+    assert!(!size_limit_is_tight(200, 1365 * 2048), "0.6 bits per pixel: a smooth demo scene fits at quality 87");
+    assert!(!size_limit_is_tight(1024, 0));
+}
+
 #[test]
 fn hdr_jpeg_size_limit_respected() {
     let img = Rgba8::from_fn(256, 192, |x, y| {
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
@@ -19,7 +19,7 @@ pub use confirm::confirm_dialog;
 pub use cull::{MAX_BURST_GAP, clamp_cull_burst, cull_params, fmt_gap, parse_gap};
 use export::{
     LIMIT_KB, QUALITY, START_NUMBER, WM_IMAGE_WIDTH, WM_INSET_X, WM_INSET_Y, WM_OPACITY, WM_POSITIONS, WM_SIZE, export_dialog_params, export_size,
-    group_checklist, watermark_preview,
+    group_checklist, size_limit_tight, watermark_preview,
 };
 pub use frame::{ConfirmStyle, confirm_style, list_height, prompt};
 use frame::{
@@ -808,10 +808,19 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) {
                         if num(ui, &LIMIT_KB, &mut k) {
                             *limit_kb = k as u32;
                         }
+                        if *limit_kb > 0 {
+                            let note = "The limit picks the highest quality, up to Quality, whose file fits.";
+                            ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim));
+                        }
                     }
                     if rendered {
                         export_size(ui, full_size, resize, &mut opts.ppi);
                     }
+                    // issue #3033: too few bytes per pixel break skies into flat blocks
+                    if opts.format == F::Jpeg && *limit_kb > 0 && size_limit_tight(app, *full_size, resize, *limit_kb) {
+                        let warn = "At this size the limit leaves little data per pixel, so skies and other smooth areas can turn blotchy. A smaller size or a larger limit keeps more quality.";
+                        ui.label(egui::RichText::new(crate::i18n::tr(warn)).color(t.caution));
+                    }
                     if rendered {
                     choices(
                         ui,
diff --git a/crates/ui-egui/src/dialogs/export.rs b/crates/ui-egui/src/dialogs/export.rs
--- a/crates/ui-egui/src/dialogs/export.rs
+++ b/crates/ui-egui/src/dialogs/export.rs
@@ -119,6 +119,24 @@ pub(super) fn watermark_preview(
     crate::widgets::register(ui, "image:exportWatermarkPreview", r.rect);
 }
 
+/// Whether a `limit_kb` JPEG size limit is tight ([`lightcraft_engine::export::size_limit_is_tight`])
+/// for the largest photo being exported (the selection, else the active photo) at the dialog's
+/// size: its cropped size, as [`lightcraft_engine::export::output_size`] gives it to the export.
+/// The largest size is worked out again only when the catalog, the photos or the size change.
+pub(super) fn size_limit_tight(app: &mut LightcraftApp, full_size: bool, resize: &lightcraft_engine::export::Resize, limit_kb: u32) -> bool {
+    use lightcraft_engine::export::{ExportOptions, output_size, size_limit_is_tight};
+    let resize = (!full_size).then_some(*resize);
+    let s = &app.session;
+    let size = resize.map(|r| (r.mode as u8, r.value.to_bits(), r.height, r.dont_enlarge));
+    let what = ("exportLargestOutput", &s.selection.ids, s.selection.active, size);
+    let pixels = app.caches.dialog_count(&s.catalog, what, || {
+        let o = ExportOptions { resize, ..Default::default() };
+        let largest = s.selected().iter().filter_map(|id| s.catalog.photo(*id)).map(|p| output_size(p, &o)).map(|(w, h)| w.saturating_mul(h)).max();
+        largest.unwrap_or(0)
+    });
+    size_limit_is_tight(limit_kb, pixels)
+}
+
 /// The Export dialog's choices as `app.export` params (without the folder).
 pub(super) fn export_dialog_params(
     opts: &lightcraft_engine::export::ExportOptions,
diff --git a/crates/ui-egui/src/export_task.rs b/crates/ui-egui/src/export_task.rs
--- a/crates/ui-egui/src/export_task.rs
+++ b/crates/ui-egui/src/export_task.rs
@@ -27,6 +27,9 @@ pub struct ExportTask {
     rx: Receiver<Result<(Vec<Value>, Option<Value>, bool), String>>,
     /// The export's row in the activity stack (`activity.cancel` sets `cancel`).
     guard: TaskGuard,
+    /// The JPEG quality asked for when a file-size limit is set: the toast says when the limit
+    /// settled below it ([`limit_quality_note`]).
+    limit_from: Option<u8>,
 }
 
 impl ExportTask {
@@ -45,6 +48,7 @@ pub fn start(app: &mut LightcraftApp, items: Vec<PreparedExport>, opts: ExportOp
     }
     let write = app.services.write_shared.clone().ok_or("no background writer")?;
     let total = items.len();
+    let limit_from = opts.limit_kb.map(|_| opts.quality);
     let progress = Arc::new(Mutex::new((0, String::new())));
     let cancel = Arc::new(AtomicBool::new(false));
     let (tx, rx) = channel();
@@ -81,7 +85,7 @@ pub fn start(app: &mut LightcraftApp, items: Vec<PreparedExport>, opts: ExportOp
     std::thread::Builder::new().name("export".into()).spawn(work).map_err(|e| e.to_string())?;
     #[cfg(target_arch = "wasm32")]
     work();
-    app.export = Some(ExportTask { total, progress, cancel, rx, guard });
+    app.export = Some(ExportTask { total, progress, cancel, rx, guard, limit_from });
     Ok(json!({"background": true, "total": total}))
 }
 
@@ -128,7 +132,7 @@ pub fn start_contact_sheet(app: &mut LightcraftApp, params: &Value) -> Result<Va
     work();
     let row = app.session.activity.start("export", "Exporting contact sheet", Cancel::Flag(cancel.clone()));
     row.progress(0, total as u64);
-    app.export = Some(ExportTask { total, progress, cancel, rx, guard: row });
+    app.export = Some(ExportTask { total, progress, cancel, rx, guard: row, limit_from: None });
     Ok(json!({"background": true, "total": total}))
 }
 
@@ -181,7 +185,7 @@ pub fn start_print(app: &mut LightcraftApp, params: &Value) -> Result<Value, Str
     work();
     let row = app.session.activity.start("export", "Printing to PDF", Cancel::Flag(cancel.clone()));
     row.progress(0, total as u64);
-    app.export = Some(ExportTask { total, progress, cancel, rx, guard: row });
+    app.export = Some(ExportTask { total, progress, cancel, rx, guard: row, limit_from: None });
     Ok(json!({"background": true, "total": total}))
 }
 
@@ -207,6 +211,22 @@ pub fn apple_photos_note(outcome: &Value) -> String {
     m
 }
 
+/// Below this JPEG quality a file-size limit leaves smooth areas such as skies blotchy (issue #3033).
+/// The Export dialog's warning estimates the same thing beforehand from the bits per pixel
+/// (`lightcraft_engine::export::TIGHT_LIMIT_BITS_PER_PIXEL`); this reports what the encode did.
+const LOW_LIMIT_QUALITY: u64 = 40;
+
+/// The toast's account of a file-size limit set with quality `requested`: the lowest quality it
+/// settled on across `files`, and what to do when that is low. None when the limit lowered no file.
+pub fn limit_quality_note(files: &[Value], requested: u8) -> Option<String> {
+    let q = files.iter().filter_map(|f| f.get("quality").and_then(Value::as_u64)).filter(|&q| q < u64::from(requested)).min()?;
+    Some(if q < LOW_LIMIT_QUALITY {
+        crate::i18n::tr_format!("JPEG quality {q} to fit the size limit (a smaller size keeps more quality)", q = q)
+    } else {
+        crate::i18n::tr_format!("JPEG quality {q} to fit the size limit", q = q)
+    })
+}
+
 /// Per frame: when an Apple Photos import that no export task reports finishes (`app.export`
 /// without `background`, `export.addToPhotos`), say how it went.
 #[cfg(not(target_arch = "wasm32"))]
@@ -239,6 +259,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
         Ok(r) => {
             let cancelled = matches!(&r, Ok((_, _, true)));
             let total = task.total;
+            let limit_from = task.limit_from;
             app.export = None;
             // Photos refused (permission, not installed…): leave the way out on screen longer
             let photos_failed = matches!(&r, Ok((_, Some(p), _)) if p.get("error").is_some());
@@ -262,6 +283,10 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
                     if let Some(first) = failed.first() {
                         m += &crate::i18n::tr_format!(" · {} failed: {}", failed.len(), first["error"].as_str().unwrap_or("error"));
                     }
+                    if let Some(note) = limit_from.and_then(|q| limit_quality_note(&files, q)) {
+                        m += " · ";
+                        m += &note;
+                    }
                     if cancelled {
                         m += " · cancelled";
                     }
@@ -402,6 +427,18 @@ mod contact_sheet_tests {
 mod tests {
     use super::*;
 
+    /// Issue #3033: the toast says which quality a size limit settled on (the lowest of the batch)
+    /// and, when it is low, that a smaller size keeps more; nothing when the limit lowered nothing.
+    #[test]
+    fn a_size_limit_quality_reads_well() {
+        assert_eq!(limit_quality_note(&[json!({"path": "a.jpg"})], 90), None);
+        assert_eq!(limit_quality_note(&[json!({"path": "a.jpg", "quality": 90})], 90), None, "the limit changed nothing");
+        let fine = limit_quality_note(&[json!({"path": "a.jpg", "quality": 90}), json!({"path": "b.jpg", "quality": 72})], 90);
+        assert_eq!(fine.as_deref(), Some("JPEG quality 72 to fit the size limit"));
+        let low = limit_quality_note(&[json!({"path": "a.jpg", "quality": 12}), json!({"error": "disk full"})], 90);
+        assert_eq!(low.as_deref(), Some("JPEG quality 12 to fit the size limit (a smaller size keeps more quality)"));
+    }
+
     /// The toast's Apple Photos part (issue #236): the count and album, a partial import's
     /// warning, an error with what to do, a cancelled export.
     #[test]
diff --git a/crates/ui-egui/src/headless/tests.rs b/crates/ui-egui/src/headless/tests.rs
--- a/crates/ui-egui/src/headless/tests.rs
+++ b/crates/ui-egui/src/headless/tests.rs
@@ -1875,6 +1875,43 @@ fn export_dialog_drops_a_depth_the_format_lacks() {
     assert_eq!(opened(&mut h, json!({"format": "tiff", "bitDepth": 32})).bit_depth, Some(32));
 }
 
+/// Issue #3033: with a size limit the dialog says the limit picks the quality, and warns when
+/// the limit leaves too little data per pixel at the size the export writes: the cropped size,
+/// worked out again when the crop changes but not on every frame.
+#[test]
+fn export_dialog_warns_when_a_size_limit_is_tight() {
+    let mut h = demo([1200.0, 1400.0]);
+    let t = Duration::from_secs(10);
+    let shown = |h: &mut Headless, last: Value| {
+        h.app.ui.dialog = None;
+        h.app.session.last_export = Some(last);
+        h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t);
+        h.step();
+        let text = h.painted_text().join("\n");
+        (text.contains("The limit picks the highest quality"), text.contains("smooth areas can turn blotchy"))
+    };
+    assert_eq!(shown(&mut h, json!({"format": "jpeg", "limitKb": 1})), (true, true), "1 KB is tight at any size");
+    assert_eq!(shown(&mut h, json!({"format": "jpeg", "limitKb": 1024, "longEdge": 512})), (true, false));
+    assert_eq!(shown(&mut h, json!({"format": "jpeg", "longEdge": 512})), (false, false), "no limit, nothing to say");
+
+    // full size: a limit at 0.4 bits per pixel of the whole frame, which a crop to a quarter of it
+    // leaves at 1.6
+    h.app.session.selection.ids.clear();
+    let id = h.app.session.selection.active.expect("an active photo");
+    let p = h.app.session.catalog.photo(id).expect("the photo");
+    let kb = (0.4 * f64::from(p.width) * f64::from(p.height) / 8192.0) as u64;
+    let full = json!({"format": "jpeg", "limitKb": kb, "resize": null});
+    assert_eq!(shown(&mut h, full.clone()), (true, true), "uncropped");
+    let scans = h.app.caches.dialog_count_scans;
+    h.step();
+    h.step();
+    assert_eq!(h.app.caches.dialog_count_scans, scans, "unchanged frames don't look the photos up again");
+    let mut d = (*h.app.session.develop_of(id).expect("settings")).clone();
+    d.crop.geometry.rect = lightcraft_geom::Rect { x0: 0.0, y0: 0.0, x1: 0.5, y1: 0.5 };
+    h.app.session.set_develop(id, d, "Crop").expect("crop");
+    assert_eq!(shown(&mut h, full), (true, false), "cropped to a quarter");
+}
+
 /// Review of #1966: a `path` whose extension names no format exports JPEG here, so its
 /// `bitDepth` is checked against JPEG's, not let through to an 8-bit file.
 #[test]
diff --git a/docs/control-protocol.md b/docs/control-protocol.md
--- a/docs/control-protocol.md
+++ b/docs/control-protocol.md
@@ -95,7 +95,7 @@ filmstrip is visible.
 | `ui.dropFiles` | `{paths: [..], hover?}` | Files dragged from the file manager. `hover: true` makes every following frame report `paths` as hovered over the window (the drop card appears, `ui.widgets` lists `dropOverlay`) until another `ui.dropFiles` replaces them; `{paths: [], hover: true}` ends the hover. Without `hover` the files are dropped: the hover ends and the next frame receives them as dropped files, exactly as an OS drop arrives (photos and folders open the import review, or import at once with Settings ▸ Import ▸ Import dropped files without review). An empty path stands for a file whose path the platform doesn't tell |
 | `ui.zoomFactor` | `{factor}` | Set the interface scale (0.5–3; egui's zoom keys are off, Cmd+= / Cmd+- zoom the photo) |
 | `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. A `path` that did not exist when the request was admitted is written with create-new semantics and is never replaced if a file (including a newly imported original) appears there while the capture is queued; an existing ordinary file may be refreshed. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically. While the loupe still waits for a render, `ui.widgets {filter: "loupe:rendering"}` lists its "Rendering…" pill; `loupe:renderFailed` is the pill saying a render failed behind a stand-in; Compare and Reference panes have their own (`compare:rendering:<pane>`, `compare:renderFailed:<pane>`) |
-| `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background`; `destinationMode: "sameAsOriginal"` writes each photo into its original's folder (+ `subfolder`) instead of `dir` | Writes the files and returns `{files}` (one `{path, width, height, bytes, sidecars}` per file, plus `gpuFallback` — why that render fell back from the GPU to the CPU — when it did; see `docs/gpu-pipeline.md`); with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` |
+| `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background`; `destinationMode: "sameAsOriginal"` writes each photo into its original's folder (+ `subfolder`) instead of `dir` | Writes the files and returns `{files}` (one `{path, width, height, bytes, sidecars}` per file, plus `gpuFallback` — why that render fell back from the GPU to the CPU — when it did; see `docs/gpu-pipeline.md`, and `quality` — the JPEG quality a `limitKb` size limit settled on — when one was set); with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` |
 | `engine.execute {command: "activity.list"}` | — | The long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first: `{tasks: [{id, kind, label, done, total, unit, detail, cancellable, cancelling, ageMs}]}`; `total` 0 = not known yet; `unit` is `count`, `bytes` or `percent`; `label` is English (the app shows it translated) |
 | `engine.execute {command: "activity.cancel"}` | `{id}` or `{all: true}` | Asks the task (or every cancellable one) to stop at its next file or photo → `{cancelled: n}`; an unknown id (it may have just finished) or a task that can't be cancelled is an error |
 | `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` |
diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md
--- a/docs/parity-checklist.md
+++ b/docs/parity-checklist.md
@@ -1,6 +1,6 @@
 # Lightroom parity checklist
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (LR-EXP-QUALITY: a JPEG size limit reports the quality it settled on, and the Export dialog warns when the limit is tight for the export size, #3033; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
 
 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we
 have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good
@@ -392,7 +392,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
 | LR-EXP-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/dialogs.rs`, `crates/engine/src/export.rs` | batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only; on a short window the options scroll and Cancel / OK stay visible (LR-BEHAV-DIALOGS, issue #781) |
 | LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests/export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, JPEG XL (issue #247: `jixel`, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; `crates/codecs/src/jxl_encode.rs`), DNG (raw photos: lossless re-encode with the edits in the embedded XMP; a raw without colour matrices of its own, as a NEF, gets the colour it opens with as `ColorMatrix1` / `ForwardMatrix1` / `AsShotNeutral`, also in Convert / Copy as DNG, so other raw converters open it, issue #1745), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build. Opaque 8-bit PNG/TIFF/WebP exports omit the redundant alpha channel; real transparency is retained (#1670). |
 | LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `fit`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/dialogs.rs` | full size = the cropped native size (no longer upscaled); both of Lightroom's boxes: Dimensions (W × H fits either orientation) and Width & Height (`fit: widthHeight`, each edge bound to its own number, #1620); ppi written to JFIF / pHYs / TIFF tags; at most 65,535 px an edge, and an export enlarged past the photo's size stops at 512 megapixels (Lightroom's ceiling too) and is refused, with the size it would need, when it needs more memory than the computer has (it aborted the process before) |
-| LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`), `crates/codecs/src/jpeg_par.rs` | 4:4:4 chroma (4:2:0 when a file-size limit is set); Huffman tables made for each image from its own symbol counts (T.81 Annex K.2) instead of the example tables: the same pixels, 12–14 % smaller files at quality 80–100 on a 24 MP photo-like image, still interleaved baseline (issue #1373); ordinary and HDR gain map JPEGs search quality up to the requested maximum to fit `limitKb`, including gain map and metadata, or return an error. Tested by `size_limit_respected` and `hdr_jpeg_size_limit_respected`. |
+| LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`), `crates/codecs/src/jpeg_par.rs` | 4:4:4 chroma (4:2:0 when a file-size limit is set); Huffman tables made for each image from its own symbol counts (T.81 Annex K.2) instead of the example tables: the same pixels, 12–14 % smaller files at quality 80–100 on a 24 MP photo-like image, still interleaved baseline (issue #1373); ordinary and HDR gain map JPEGs search quality up to the requested maximum to fit `limitKb`, including gain map and metadata, or return an error. Each limited file reports the quality it settled on (`quality` in the `app.export` result); the export toast names it when the limit lowered it, with a smaller-size hint below 40; the Export dialog says the limit picks the quality and warns when the limit leaves under 0.5 bits per pixel at the cropped export size of the largest photo, where skies break into flat blocks (#3033). Tested by `size_limit_respected`, `hdr_jpeg_size_limit_respected`, `a_size_limit_reports_the_quality_it_settled_on`, `a_size_limit_quality_reads_well` and `export_dialog_warns_when_a_size_limit_is_tight`. |
 | LR-EXP-BITDEPTH | Bit depth | P1 | ✅ | `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF/JPEG XL rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU |
 | LR-EXP-COMPRESSION | TIFF compression | P1 | ✅ | `cmd:app.export` (`tiffCompression`: none / lzw / zip) | |
 | LR-EXP-COLORSPACE | Output colour space | P0 | ✅ | `cmd:app.export` (`colorSpace`), `crates/pipeline/src/output.rs`, `crates/engine/src/export.rs` | sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB, Rec. 2020: rendered from the working space with gamut mapping into the target gamut (CPU + GPU), own ICC profile embedded; AVIF stays sRGB (muxer has no ICC); lossy JPEG XL declares sRGB / P3 / Rec. 2020 (Adobe RGB and ProPhoto written as Rec. 2020), lossless JPEG XL embeds the profile |
@@ -959,3 +959,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
 | 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) |
 | 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone |
 | 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) |
+| 2026-10-27 | minor | LR-EXP-QUALITY: a JPEG size limit reports the quality it settled on (`quality` per file in the `app.export` result; the export toast names it when the limit lowered it, with a smaller-size hint below quality 40); the Export dialog says the limit picks the quality up to Quality and warns when the limit leaves under 0.5 bits per pixel at the cropped export size, where skies break into flat blocks (#3033); status unchanged (✅) |
