Files
photocraft/docs/integration-testing/integration-checkpoint.md
T

230 KiB
Raw Blame History

Integration checkpoint and decisions

Partial integration: 108 of 108 selected heads are ancestors; 0 require resolution. No integrated build, corpus, full sweep or manual interaction qualification is claimed. See test plan, selection evidence and machine-readable status.

Latest progress (updated 2026-10-29T05:45:40.679773+00:00)

  • Verified local integrated heads: 108 / 108, checkpoint 7acb18325654d0b8a56f95cada9ca4186d45dc43.
  • Layer Style sizing/layout/defaults and profile capture ports are committed; action JSON, definition history, named targets and starter actions are committed.
  • CPU compositor checkpoint at 31ce2208…: 402 PASSED, 0 FAILED, 2 IGNORED. Later code is not yet fully validated.
  • Parent approved #5176 compatibility migration: atomically commit the new authoritative index last; preserve legacy/corrupt files and never resurrect stale entries. Implementation committed; 35 preset-store tests PASSED at d98e4195, including all four migration regressions; one benchmark ignored. Final-tree rerun remains pending.
  • Final Windows portable artifact: NOT RUN until complete integration and applicable validation. Protocol correction is IN PROGRESS; generic historical per-PR cases are not accepted as complete.

Scope holds

  • #7098 excluded: successful current-head rollup contains only CodeRabbit; its official expanded corpus distribution remains pending.
  • #3226 held: latest approval explicitly calls the provenance wording confirmation non-blocking, but asks maintainers to confirm it before landing. Parent decision required before counting it.
  • #7848 prerequisite #7281 is merged upstream (2026-10-24); its corrective behavior still needs porting and new integrated validation.
  • Both #9339 and #9342 retained: session-aware Export As conversion and IO/Save-for-Web embedding are complementary.
  • #9509 is now integrated in compose/src/composite.rs; #9512 still requires combining its Fill behavior with that HDR precision change.

Concrete resolution proposals

  1. Canvas #2300: keep current split helpers and animated display-camera flow. Run stale-polygon cancellation and edge-scroll against the target camera before deriving the transforms; request repaint on center changes. Port its edge/corner, flip, rotation, aspect, pause and ownership regressions into current canvas tests. Do not restore the old monolithic body.
  2. Adjustment #9512: extend current blend_back with Fill, retain blend_rgb_fill for special modes and retain #9509's let j = 1.0-k; b*j+blended*k. Port GPU planner changes into current planner modules; clipped adjustments keep Fill as coverage. Run both PR regression suites plus combined HDR/Fill/Opacity/mask cases.
  3. Action stack #1991/#3455/#3461/#5090/#5176: preserve current split engine/UI action architecture. Port registry entries, bounded JSON validation, separate definition-history state, playback target resolution and tests; preserve every existing ambient-path authorization case when adding actions.export. Deleted old files must stay deleted.
  4. Profile #9339/#9342: preserve captured effective working profile through preview, exact-size worker, replay and layer batches. Add IO embedding defaults without relabeling unconverted P3/Adobe RGB pixels as sRGB. Preserve embedding-off and source-immutability regressions.
  5. Layer Style #4938 → #5010: keep current dialog dispatch/modules, port shared fixed sizing and three-column layout there, then #9517 scrolling and #9481 defaults. Preserve current dialogs and test-module split. Test short screens and every theme/scale.

These proposals are pending parent semantic review. Mechanical moved-test/catalog/workflow resolutions are already committed; they preserve both sides' independent behavior.

Selected PRs by subsystem

Actions and automation (11)

  • #1991 feat(actions): import and export reusable Actions JSON files — INTEGRATED
  • #3455 Undo and redo action definition edits from the Actions panel — INTEGRATED
  • #3461 Replay recorded layer visibility on the intended named layer — INTEGRATED
  • #3748 control: suggest near matches for unknown command ids and methods (#3674) — INTEGRATED
  • #4993 Prevent control-channel engine commands from editing behind modal dialogs — INTEGRATED
  • #5090 Actions: add editable PhotoCraft Essentials starter presets — INTEGRATED
  • #5176 Play CopyCat, AJ and Line and Color action sets; keep patterns across restarts; Shift Edge scale — INTEGRATED
  • #6492 Fail control startup promptly when its port is occupied — INTEGRATED
  • #6510 Automation: resolve configured root aliases for absolute paths (#6426) — INTEGRATED
  • #7742 Actions: a fractional setting an integer field cannot hold is refused, not rounded — INTEGRATED
  • #8905 fix(plugins): bound a run's total plug-in memory, not just each instance — INTEGRATED

Compositing and filters (20)

  • #3874 fix(layer-style): support Blend If Alt-split handle feathering and throttle live preview — INTEGRATED
  • #3967 Add move and delete actions to Smart Filter rows — INTEGRATED
  • #4305 Filter > Other > Frequency Separation (#1714) — INTEGRATED
  • #4711 Treat Dissolve as a per-pixel choice — INTEGRATED
  • #4992 Preserve Dissolve coverage when compositing clipped layers — INTEGRATED
  • #7153 Smart Filters: double-clicking a Liquify filter reopens it in the Liquify workspace — INTEGRATED
  • #7903 Filter Gallery › Cutout 4/4: trace the image into flat paper shapes — INTEGRATED
  • #8039 Compose: Extend Softer outer glows past Size (#7899) — INTEGRATED
  • #8191 Filter Gallery › Cutout 1/4: colour and palette stage — INTEGRATED
  • #8192 Filter Gallery › Cutout 2/4: regions stage — INTEGRATED
  • #8193 Filter Gallery › Cutout 3/4: edges and curves stage — INTEGRATED
  • #8903 Box-filter minified patterns in the CPU and GPU samplers (#8831) — INTEGRATED
  • #8931 feat(algo): describe the Gaussian blur as data for other devices — INTEGRATED
  • #9472 fix(io): import a Displace smart filter that names its map file without embedding it (#9162) — INTEGRATED
  • #9509 fix: an edit at full coverage keeps its result over huge 32-bit values — INTEGRATED
  • #9512 fix(compose, gpu): adjustment layers apply Fill inside the special eight blend modes — INTEGRATED
  • #9584 fix(compose, gpu): Blend If hard points match Photoshop at 16 bits and for Gray — INTEGRATED
  • #9589 fix(compose, gpu): an adjustment layer's This Layer range judges its result before Opacity — INTEGRATED
  • #9595 fix(compose, gpu): a Pass Through group with Blend If renders isolated — INTEGRATED
  • #9651 perf(algo): row-parallel local_sharpen with fast-path protect_detail (#5426) — INTEGRATED

File fidelity and colour (15)

  • #3142 Read applied Sony RAW metadata and crop ILCE-6000 images — INTEGRATED
  • #3387 feat(codecs): add lossless JPEG XL export for 8/16-bit images — INTEGRATED
  • #3519 feat(export): optionally trim transparent margins from layer files — INTEGRATED
  • #4035 fix(cmyk): Flatten Image and merges keep the inks of a tagged CMYK document (#3778) — INTEGRATED
  • #5870 fix(format): reject duplicate native layer identities — INTEGRATED
  • #6387 Video Frames to Layers: every page of a multi-page TIFF (part of #6379) — INTEGRATED
  • #8897 fix(io): read linked smart objects for PSD export as regular files only — INTEGRATED
  • #9339 fix(export): embed requested sRGB profile for untagged sources (#9325) — INTEGRATED
  • #9342 fix(io, engine): Embed Color Profile writes sRGB for an untagged document (#9325) — INTEGRATED
  • #9401 fix(format): a save on macOS keeps the file's creation time (#9388) — INTEGRATED
  • #9416 Affinity reader: contiguous curve records and a cheaper object tag index — INTEGRATED
  • #9418 Color Lookup: honour the .3dl Mesh header's cube edge and output depth (#9078, #9073) — INTEGRATED
  • #9457 raw: faster tone stage (Shadows, Highlights, Clarity, Texture, Dehaze), same bytes — INTEGRATED
  • #9590 fix(io): PSD layer-effect contours keep at most 16 points — INTEGRATED
  • #9596 fix(io): drop a kept Txt2 once no type layers are left — INTEGRATED

Painting (9)

  • #4763 refactor(liquify): reuse native brush resizing and anchored HUD feedback — INTEGRATED
  • #4985 feat(ui): add Shift+right-click brush mode picker — INTEGRATED
  • #5082 Mixer Brush Alt-click loads reservoir while preserving foreground — INTEGRATED
  • #5165 Fix: brush section selection — INTEGRATED
  • #5939 fix(paint): scale blur sigma with brush size (#5892) — INTEGRATED
  • #6139 Fix painting workflows, File Info and layer selection — INTEGRATED
  • #6236 Shift+Alt+right-click opens Photoshop's HUD Color Picker (#6098) — INTEGRATED
  • #7848 fix(retouch): preserve Smudge pickup with live-pixel stroke commits — INTEGRATED
  • #9623 Pencil: keep a 1 px freehand line connected where it turns — INTEGRATED

Platform and reliability (8)

  • #3554 fix(packaging): launch bundled CLI directly from AppImage — INTEGRATED
  • #4742 fix(macos): bundle photocraft-cli and resolve it from droplet scripts — INTEGRATED
  • #6383 Windows releases ship a symbols zip: the release build's PDBs for profilers (#688) — INTEGRATED
  • #7743 packaging: the portable zip holds the files, not a folder with the files in it — INTEGRATED
  • #9015 fix(xtask): reject leading zeroes in numeric prerelease identifiers — INTEGRATED
  • #9019 fix(xtask): reject unknown performance benchmark selections — INTEGRATED
  • #9110 test: GPU tests honour WGPU_BACKEND, so they run on Windows with an Intel GPU (#4) — INTEGRATED
  • #9436 Startup: a failed launch says why in a dialog instead of closing silently (#7643) — INTEGRATED

Selection, paths and transforms (13)

  • #2300 Scroll the viewport while outlining with Polygonal Lasso — INTEGRATED
  • #3464 fix(vector): target selected saved paths and own Pen gestures — INTEGRATED
  • #5453 Shapes: independent fill and stroke paints (solid, gradient, pattern), part 1 — INTEGRATED
  • #5493 Import Photoshop custom shape sets into the Shapes panel — INTEGRATED
  • #5515 Selecting subject now automatically applies to selected layer, and us… — INTEGRATED
  • #5650 Guides: ⌥ turns a dragged guide; dropping one on either ruler deletes it (#5034) — INTEGRATED
  • #6026 Marquee: Fixed Size selects exactly the entered width and height (fixes #6015) — INTEGRATED
  • #6621 Crop: right-click on the pending crop opens the crop menu (#6561) — INTEGRATED
  • #7648 Pen: Path Operations menu, as in Photoshop (#1397) — INTEGRATED
  • #8074 Transform: remember selected interpolation (#8067) — INTEGRATED
  • #8896 Select and Mask: Photoshop's full-window workspace (#7069, #8148) — INTEGRATED
  • #8986 Path tools: edit the saved path selected in the Paths panel (#8478) — INTEGRATED
  • #9493 fix(vector): a mask feather continues the mask past the canvas edge — INTEGRATED

Type and typography (6)

  • #3139 Draw right-to-left layer names in visual word order — INTEGRATED
  • #4783 Fix/4758 type extreme finite geometry 20261013s1215 — INTEGRATED
  • #8347 Type: Roman Hanging Punctuation (trailing marks hang, don't steer the alignment) — INTEGRATED
  • #9478 fix(text): vertical type sets roman letters upright, one per em, as Photoshop does (#9470) — INTEGRATED
  • #9523 Fix Type on a Path wrapping and caret positioning across closed-path seams — INTEGRATED
  • #9594 fix(engine): copied type keeps its size across resolutions after an edit — INTEGRATED

UI and workflow (26)

  • #3345 Add grouped gradient presets and selectable style icons — INTEGRATED
  • #3520 fix(ui): restore square color chips in Studio Dark theme — INTEGRATED
  • #4023 i18n(es): add 548 missing rows, fix five filter and video terms — INTEGRATED
  • #4042 feat(canvas): rotate view with Shift + middle-mouse drag — INTEGRATED
  • #4547 fix(icons): PhotoCraft icon corner radius matches other Craft apps (#4478) — INTEGRATED
  • #4883 feat(ui): show selection and active-layer bounds in the status bar — INTEGRATED
  • #4890 Add scrollbar visibility preferences (#3262) — INTEGRATED
  • #4938 Layer Style: one dialog size on every page, and it fits short screens — INTEGRATED
  • #5010 Layer Style: Photoshop's layout (three columns, OK/Cancel/New Style/Preview on the right, one-line rows) [depends on #4938] — INTEGRATED
  • #5270 Layer Style: Gradient Overlay takes any number of colour stops (#5177) — INTEGRATED
  • #5744 fix(ui): keep detached documents in one interactive window — INTEGRATED
  • #6449 Hand tool: Cmd held zooms in and Alt zooms out, as in Photoshop — INTEGRATED
  • #7255 fix(engine): guard trim and duplicate merged against documents exceeding render limits (#7223) — INTEGRATED
  • #8892 ui: larger Align icons and overflow button; Save for Web keeps its width on an invalid Matte — INTEGRATED
  • #8985 Color panel and Color Picker: clip the round cursor to the colour field — INTEGRATED
  • #9004 fix(ui): enlarge Move tool alignment icons for legibility — INTEGRATED
  • #9172 Gradient Fill layer: default to Foreground to Transparent (#8708) — INTEGRATED
  • #9282 feat(ui): Preset menus for Levels, Exposure, Black & White and Channel Mixer — INTEGRATED
  • #9354 fix(ui): Full Screen Mode needs a document (#9349) — INTEGRATED
  • #9481 fix(ui): new layer effects start from the Make Default values — INTEGRATED
  • #9504 fix(ui): Keyboard Shortcuts records ⌘Z instead of undoing the capture — INTEGRATED
  • #9517 fix(ui): Layer Style scrolls on a short window so OK stays on screen — INTEGRATED
  • #9597 fix(ui): the layer list doesn't move with no layer selected — INTEGRATED
  • #9598 fix(ui): Liquify's Twirl Counterclockwise gets a mirrored twirl icon — INTEGRATED
  • #9606 fix(ui): the Color panel remembers its options across restarts — INTEGRATED
  • #9703 Fix adjustment layer name clicks targeting masks — INTEGRATED

Pending conflict files and exact source deltas

The excerpts below are PR changes from GitHub-fetched heads, not approved resolutions. They supplement the decisions above.

#9703: Fix adjustment layer name clicks targeting masks

crates/ui-egui/src/panels.rs

diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
index b86d7b2af..5d9e8bd97 100644
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -3490,8 +3490,7 @@ fn layer_row(
         let mode = if m.command && on_thumb && doc.is_background(l.id) { "replace" } else { select_mode(m) };
         actions.push(("layer.select".into(), json!({"layer": l.id.0, "mode": mode})));
         crate::smart_ui::deselect(ctx, doc.id);
-        // A thumbnail explicitly selects content or a mask. Selecting an adjustment/fill row
-        // defaults to its mask, but clicking its content thumbnail must leave the mask target.
+        // Adjustment names target contents; fill rows keep their existing mask default.
         let on_mask = mask_rect.zip(pos).is_some_and(|(r, p)| r.expand(2.0).contains(p));
         let on_vector = pos.and_then(|p| masks.hit(p)) == Some(crate::mask_thumbs_ui::MaskKind::Vector);
         // Clicking the layer thumbnail leaves mask view (#196).
@@ -3500,19 +3499,20 @@ fn layer_row(
             let mask = if v.filter { "filter" } else { "layer" };
             actions.push((photocraft_engine::mask_view_cmds::ID.into(), json!({"layer": l.id.0, "mode": "off", "mask": mask})));
         }
-        let content_less = matches!(l.content, LayerContent::Adjustment(_) | LayerContent::Fill(_));
+        let adjustment = matches!(l.content, LayerContent::Adjustment(_));
+        let fill_mask = matches!(l.content, LayerContent::Fill(_)) && l.mask.is_some();
         if on_vector {
             // The vector mask thumbnail targets the vector mask: the path tools edit it and the
             // Paths panel selects the layer's path.
             app.ui.selected_path = Some("layer".into());
             actions.push(("ui.vectorMaskTarget".into(), json!(true)));
-        } else if on_mask || (content_less && l.mask.is_some() && !on_thumb) {
+        } else if on_mask || (fill_mask && !on_thumb) {
             if on_mask {
                 // The mask thumbnail also switches Properties to the mask (#3369).
                 app.ui.properties_layer = None;
             }
             actions.push(("ui.maskTarget".into(), json!(true)));
-        } else if on_thumb || !row.primary {
+        } else if on_thumb || adjustment || !row.primary {
             actions.push(("ui.maskTarget".into(), json!(false)));
         }
         // ⌥-click the layer thumbnail: fit the layer on screen, as Photoshop does.

#9606: fix(ui): the Color panel remembers its options across restarts

crates/ui-egui/src/panels.rs, crates/ui-egui/src/prefs_ui.rs, docs/scorecard.md

diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
index b86d7b2af..1f76a0f90 100644
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -1868,6 +1868,7 @@ pub fn dock_area(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
     }
     egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| crate::panel_docking::show(app, ui));
     crate::dock::persist(app, ui.ctx());
+    crate::color_panel_ui::persist(app);
 }
 
 /// The docking layout's protected canvas panel: the document area, with the icon rail on its end
diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs
index 68ebf5bd9..2369b38fd 100644
--- a/crates/ui-egui/src/prefs_ui.rs
+++ b/crates/ui-egui/src/prefs_ui.rs
@@ -250,6 +250,7 @@ pub fn load(app: &mut PhotocraftApp) {
         }
     }
     crate::dock::restore(app);
+    crate::color_panel_ui::restore(app);
     crate::brush_picker::restore(app);
     crate::last_brush::restore(app);
     sync_rulers(app);

#9598: fix(ui): Liquify's Twirl Counterclockwise gets a mirrored twirl icon

ATTRIBUTION.md

#9597: fix(ui): the layer list doesn't move with no layer selected

crates/ui-egui/src/panels.rs

diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
index b86d7b2af..c1be879f0 100644
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -2626,56 +2626,59 @@ fn opacity_row_layout(available: f32, label_width: f32, gap: f32) -> (f32, bool)
 /// greyed out at Normal, 100% and unlocked, instead of removing them (#8880). Same layout as the
 /// active layer's rows in `layers`.
 fn inactive_blend_rows(ui: &mut egui::Ui, pro: bool) {
-    ui.add_enabled_ui(false, |ui| {
-        ui.horizontal(|ui| {
-            let opacity_label = if pro { tl!("Opacity:") } else { tl!("Opacity") };
-            let (blend_width, show_opacity_label) = opacity_row_layout(ui.available_width(), body_text_width(ui, opacity_label), ui.spacing().item_spacing.x);
-            let mut m = BlendMode::Normal;
-            let options = blend_options(false, m);
-            widgets::dropdown_wheel_hovered(ui, "blend", tl!("Blend Mode"), &mut m, &options, blend_width);
-            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
-                let mut o = 100.0;
-                widgets::popup_value_field(ui, opacity_label, &mut o, 0.0..=100.0, "%", LAYER_PCT_W);
-                if show_opacity_label {
-                    scrub_pct_label(ui, opacity_label, &mut o, ui.available_width());
-                }
-            });
-        });
-        ui.add_space((theme::ROW_GAP - ui.spacing().item_spacing.y).max(0.0));
-        ui.horizontal(|ui| {
-            let lock_label = if pro { tl!("Lock:") } else { tl!("Lock") };
-            let lang = crate::i18n::current();
-            let fill_label = if pro { crate::i18n::tr_ctx(lang, "layers", "Fill:") } else { crate::i18n::tr_ctx(lang, "layers", "Fill") };
-            let gap = ui.spacing().item_spacing.x;
-            let icons_w = if pro { 5.0 * 20.0 + gap } else { 22.0 + gap };
-            let fits = body_text_width(ui, lock_label) + icons_w + body_text_width(ui, fill_label) + LAYER_PCT_W + 2.0 * gap <= ui.available_width();
-            if fits {
-                label(ui, lock_label);
-            }
-            if pro {
-                ui.scope(|ui| {
-                    ui.spacing_mut().item_spacing.x = 0.0;
-                    for (icon, tip) in [
-                        ("grid-3x3", tl!("Lock transparent pixels")),
-                        ("brush", tl!("Lock image pixels")),
-                        ("move", tl!("Lock position")),
-                        ("scan", tl!("Prevent auto-nesting in and out of Artboards and Frames")),
-                        ("lock", tl!("Lock all")),
-                    ] {
-                        icons::button(ui, icon, 20.0, false, tip);
-                    }
-                });
-            } else {
-                icons::button(ui, "lock-open", 22.0, false, tl!("Lock layer"));
+    // Each row is disabled on its own and laid out as the active layer's rows are: a disabled
+    // wrapper around both added an item gap under the Fill row, which pushed the layer list down
+    // in the themes whose docks keep item spacing (#9546).
+    ui.horizontal(|ui| {
+        ui.disable();
+        let opacity_label = if pro { tl!("Opacity:") } else { tl!("Opacity") };
+        let (blend_width, show_opacity_label) = opacity_row_layout(ui.available_width(), body_text_width(ui, opacity_label), ui.spacing().item_spacing.x);
+        let mut m = BlendMode::Normal;
+        let options = blend_options(false, m);
+       

#9590: fix(io): PSD layer-effect contours keep at most 16 points

crates/io/src/effects_map.rs

diff --git a/crates/io/src/effects_map.rs b/crates/io/src/effects_map.rs
index 416aa56b5..f47248bca 100644
--- a/crates/io/src/effects_map.rs
+++ b/crates/io/src/effects_map.rs
@@ -122,14 +122,48 @@ fn contour(d: &Descriptor, key: &str) -> Contour {
     let linear = points.len() == 2 && points[0].input == 0.0 && points[0].output == 0.0 && points[1].input == 1.0 && points[1].output == 1.0;
     if points.is_empty() || (linear && (name.is_empty() || name == "Linear")) { Contour::Linear } else { Contour::Custom { name, points, corners } }
 }
+/// The most points Photoshop reads in an effect's contour (`ShpC` › `Crv `): a layer whose effects
+/// carry a longer one fails to load ("A problem was encountered reading layer … program error"),
+/// and Photoshop then drops all of its effects; 16 loads (#9556).
+const PSD_CONTOUR_POINTS: usize = 16;
+
+/// Indices of the contour points a PSD keeps, in order: all of them up to [`PSD_CONTOUR_POINTS`],
+/// else both ends and then, one at a time, the point farthest (in output) from the line between
+/// the kept points around it, so peaks and valleys (Ring - Double's two rings) survive.
+fn psd_contour_points(points: &[CurvePoint]) -> Vec<usize> {
+    if points.len() <= PSD_CONTOUR_POINTS {
+        return (0..points.len()).collect();
+    }
+    let mut kept = vec![0, points.len() - 1];
+    while kept.len() < PSD_CONTOUR_POINTS {
+        let mut farthest: Option<(f32, usize)> = None;
+        for pair in kept.windows(2) {
+            let &[a, b] = pair else { continue };
+            let (Some(pa), Some(pb)) = (points.get(a), points.get(b)) else { continue };
+            let span = pb.input - pa.input;
+            for (i, p) in points.iter().enumerate().take(b).skip(a + 1) {
+                // Only equal inputs (a vertical step) have no span: inputs a few ulps apart still do.
+                let t = if span != 0.0 && span.is_finite() { (p.input - pa.input) / span } else { 0.0 };
+                let off = (p.output - (pa.output + t * (pb.output - pa.output))).abs();
+                if off.is_finite() && farthest.is_none_or(|(f, _)| off > f) {
+                    farthest = Some((off, i));
+                }
+            }
+        }
+        let Some((_, i)) = farthest else { break };
+        kept.insert(kept.partition_point(|&k| k < i), i);
+    }
+    kept
+}
+
 fn contour_value(c: &Contour) -> Value {
     let (name, points) = match c {
         Contour::Linear => ("Linear".to_string(), vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }]),
         Contour::Custom { name, points, .. } => (name.clone(), points.clone()),
     };
-    let pts = points
-        .iter()
-        .enumerate()
+    let pts = psd_contour_points(&points)
+        .into_iter()
+        .filter_map(|i| points.get(i).map(|p| (i, p)))
         .map(|(i, p)| {
             let d = Descriptor::new("CrPt").with("Hrzn", Value::Double(f64::from(p.input * 255.0))).with("Vrtc", Value::Double(f64::from(p.output * 255.0)));
             // Photoshop writes no `Cnty` (continuity) on smooth points; a corner carries `Cnty` false.
@@ -644,6 +678,47 @@ pub fn parse_lrfx(data: &[u8]) -> Option<(bool, Vec<Effect>)> {
 mod tests {
     use super::*;
 
+    fn crv(v: &Value) -> Vec<(f64, f64)> {
+        let Value::Descriptor(d) = v else { panic!("ShpC") };
+        let Some(Value::List(pts)) = d.get("Crv ") else { panic!("Crv ") };
+        pts.iter()
+            .map(|p| {
+                let V

#9589: fix(compose, gpu): an adjustment layer's This Layer range judges its result before Opacity

crates/compose/src/lib.rs, crates/gpu/src/plan.rs

diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs
index 0a3f5c480..547125999 100644
--- a/crates/compose/src/lib.rs
+++ b/crates/compose/src/lib.rs
@@ -1808,13 +1808,18 @@ thread_local! {
 /// Mixing premultiplied colour by `k` equals compositing the layer at `k` × its alpha, since
 /// every blend mode's source-over result is linear in the source alpha.
 fn apply_blend_if(layer: &Layer, before: &Buffer, out: &mut Buffer, cx: &Ctx) {
-    // "This Layer" is the layer's own colour; adjustment layers (no content of their own) are
-    // judged by their result. So are groups nested deeper than `MAX_BLEND_IF_GROUP_DEPTH` Blend
-    // Ifs: a group's own colour re-composites its children, which doubles the work at every
-    // level of Blend If groups inside one another.
+    // "This Layer" is the layer's own colour. An adjustment layer (no content of its own) is
+    // judged by its result at full strength, before Opacity and its mask mix it in, as Photoshop
+    // does (#9542). Groups nested deeper than `MAX_BLEND_IF_GROUP_DEPTH` Blend Ifs are judged by
+    // the composite: a group's own colour re-composites its children, which doubles the work at
+    // every level of Blend If groups inside one another.
     let is_group = matches!(layer.content, LayerContent::Group(_));
     let nested = BLEND_IF_GROUPS.with(std::cell::Cell::get);
-    let own = if matches!(layer.content, LayerContent::Adjustment(_)) || (is_group && nested >= MAX_BLEND_IF_GROUP_DEPTH) {
+    let own = if let LayerContent::Adjustment(adj) = &layer.content {
+        let mut result = before.clone();
+        cx.apply_adjustment(adj, &mut result);
+        Some(result)
+    } else if is_group && nested >= MAX_BLEND_IF_GROUP_DEPTH {
         None
     } else {
         BLEND_IF_GROUPS.with(|d| d.set(nested + u32::from(is_group)));
diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs
index 38cc879b0..33d4bf76f 100644
--- a/crates/gpu/src/plan.rs
+++ b/crates/gpu/src/plan.rs
@@ -410,7 +410,8 @@ pub const F_QUANT: u32 = 32768;
 /// `Lerp` as A + (B − C) premultiplied (layers clipped to pass-through groups).
 pub const F_ADD_DIFF: u32 = 16384;
 /// `Lerp`: B mixed back towards A (the backdrop) by the layer's Blend If weight
-/// (`compose::apply_blend_if`), from C ("This Layer", or B itself for an adjustment layer, `p4.y`)
+/// (`compose::apply_blend_if`), from C ("This Layer": an adjustment layer's unmixed result; B when
+/// `p4.y` is set)
 /// and A ("Underlying Layer"). The ranges are packed in `p0..p3` (`blend_if_params`); `p4.x` marks
 /// a one-channel document.
 pub const F_BLEND_IF: u32 = 524288;
@@ -563,13 +564,17 @@ impl<'a> Planner<'a> {
 
     /// `compose::apply_blend_if`: `after` (the backdrop with `layer` drawn) mixed back towards
     /// `before` (the backdrop without it) where the layer's Blend If ranges hide it. "This Layer"
-    /// is the layer's own content with its masks; an adjustment layer is judged by its result.
-    /// Consumes both slots.
+    /// is the layer's own content with its masks; an adjustment layer is judged by its result at
+    /// full strength, before Opacity and its mask (#9542). Consumes both slots.
     fn blend_if(&mut self, layer: &'a Layer, before: Slot, after: Slot) -> Result<Slot, Unsupported> {
-        let own = match layer.content {
-            LayerContent::Adjustment(_) => None,
-            _ => Some(self.content(layer)?),
+        let own = match &layer.content {
+            LayerCont

#9523: Fix Type on a Path wrapping and caret positioning across closed-path seams

crates/ui-egui/src/type_tool.rs

diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs
index e8fdbe95d..7139e693a 100644
--- a/crates/ui-egui/src/type_tool.rs
+++ b/crates/ui-egui/src/type_tool.rs
@@ -192,12 +192,12 @@ pub(crate) fn offset_at(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) ->
     let (tx, ty) = to_text(&aff, x, y);
     // Type on a path: the point's place along the path is the position in the (single) line.
     let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape.clone()));
-    let (tx, ty) = match shape {
-        Some(photocraft_doc::text::TextShape::Path { path, start, flip }) => {
-            photocraft_text::warp::arc_length_at(&path, flip, f64::from(tx), f64::from(ty)).map_or((tx, ty), |s| ((s - f64::from(start)) as f32, -1.0))
-        }
-        _ => (tx, ty),
-    };
+    let text_start = l.bounds().map_or(0.0, |bounds| f64::from(bounds[0]));
+    let (tx, ty) = shape
+        .as_ref()
+        .and_then(|shape| photocraft_text::warp::Warp::on_path_with_text_start(shape, text_start))
+        .and_then(|warp| warp.path_text_x_near_text(f64::from(tx), f64::from(ty), l.bounds().map_or(0.0, |bounds| f64::from(bounds[2]))))
+        .map_or((tx, ty), |offset| (offset as f32, -1.0));
     photocraft_text::hit_char(&l, &text, tx, ty).0
 }
 
@@ -1297,7 +1297,8 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX
     };
     let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape.clone()));
     // Type on a path: the caret, selection and underline follow the curve the glyphs sit on.
-    let bend = shape.as_ref().and_then(photocraft_text::warp::Warp::on_path);
+    let text_start = l.bounds().map_or(0.0, |bounds| f64::from(bounds[0]));
+    let bend = shape.as_ref().and_then(|shape| photocraft_text::warp::Warp::on_path_with_text_start(shape, text_start));
     // Line space (lines, clusters, carets) → screen: vertical type turns it 90° clockwise.
     let scr = |x: f32, y: f32| -> Pos2 {
         let (x, y) = l.to_text(x, y);
@@ -3255,6 +3256,109 @@ mod tests {
         assert!(app.ui.text_edit.is_none());
     }
 
+    // Independent perimeter oracle for the rectangle used by the endpoint hit tests.
+    fn closed_path_endpoint_test_point(s: f64) -> (f64, f64) {
+        if s < 200.0 {
+            (20.0 + s, 20.0)
+        } else if s < 300.0 {
+            (220.0, 20.0 + s - 200.0)
+        } else if s < 500.0 {
+            (220.0 - (s - 300.0), 120.0)
+        } else {
+            (20.0, 120.0 - (s - 500.0))
+        }
+    }
+
+    #[test]
+    fn closed_path_forward_hit_after_last_caret() {
+        for align in ["left", "center", "right"] {
+            let mut app = app();
+            app.ui.tool_options.type_align = align.into();
+            let path = photocraft_vector::shapes::rect(20.0, 20.0, 200.0, 100.0);
+            app.run("path.set", json!({"path": photocraft_engine::vector_cmds::path_json(&path)})).unwrap();
+            pointer_up(&mut app, [20.0, 45.0], [20.0, 45.0]);
+            insert(&mut app, "ssssssssssss");
+            let id = LayerId(app.ui.text_edit.as_ref().unwrap().layer);
+            let (l, _, _) = layout(&mut app, id).unwrap();
+            let (last, _, _) = l.caret(12);
+            let s = (575.0 + f64::from(last) + 2.0).rem_euclid(600.0);
+            let (x, y) = closed_path_endpoint_test_point(s);
+            assert_eq!(offset_at(&mut app, id, x, y), 12, "{align}");
+       

#9517: fix(ui): Layer Style scrolls on a short window so OK stays on screen

crates/ui-egui/src/dialogs.rs

diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 2c683bce3..f91d5208c 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -503,7 +503,12 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
                 DialogKind::Command if crate::new_guide_ui::owns(&fields) => crate::new_guide_ui::body(ui, &mut fields),
                 DialogKind::Command if fields.contains_key("__form") => crate::view_cmds::form_body(ui, &mut fields),
                 DialogKind::Command => {}
-                DialogKind::LayerStyle => crate::layer_style::body(app, ui, &mut fields),
+                DialogKind::LayerStyle => {
+                    // Bevel & Emboss is taller than a small window: the pages scroll, as a long
+                    // filter dialog does, so OK and Cancel stay on screen (#9515).
+                    let room = (ctx.content_rect().height() - DIALOG_CHROME).max(120.0);
+                    egui::ScrollArea::vertical().id_salt(id.with("body")).max_height(room).show(ui, |ui| crate::layer_style::body(app, ui, &mut fields));
+                }
                 DialogKind::Error => {
                     ui.label(fields.get("message").and_then(Value::as_str).unwrap_or("Error"));
                 }
@@ -1167,6 +1172,25 @@ mod tests {
         }
     }
 
+    /// #9515: Bevel & Emboss is taller than a small window (1435 × 858, the reporter's): its page
+    /// scrolls, so OK and Cancel stay on screen.
+    #[test]
+    fn layer_style_buttons_stay_on_a_short_window() {
+        use egui_kittest::{Harness, kittest::Queryable};
+
+        let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
+        app.run("file.new", serde_json::json!({"width": 64, "height": 64})).unwrap();
+        app.run("layer.new.layer", serde_json::json!({})).unwrap();
+        let mut h = Harness::builder().with_size(egui::vec2(1435.0, 858.0)).build_ui_state(|ui, app| show(app, ui.ctx()), app);
+        PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
+        crate::layer_style::open(h.state_mut(), Some("bevelEmboss")).unwrap();
+        h.run_steps(4);
+        for label in ["OK", "Cancel"] {
+            let r = h.get_by_label(label).rect();
+            assert!(r.bottom() <= 858.0, "{label} is off the window: {r:?}");
+        }
+    }
+
     #[test]
     fn layer_style_title_gutters_can_start_drags() {
         use egui_kittest::{Harness, kittest::Queryable};

#9512: fix(compose, gpu): adjustment layers apply Fill inside the special eight blend modes

crates/compose/src/lib.rs, crates/gpu/src/plan.rs

diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs
index 4786bc191..2c8d01a7a 100644
--- a/crates/compose/src/lib.rs
+++ b/crates/compose/src/lib.rs
@@ -1065,8 +1065,10 @@ fn branchless_floor(x: f32) -> f32 {
 }
 
 /// Blend an adjustment layer's result (`adjusted`) back into the backdrop it was made from, each
-/// pixel by its coverage `ks` (opacity × mask) in blend mode `mode`.
-fn blend_back(backdrop: &mut [[f32; 4]], adjusted: &[[f32; 4]], ks: impl Iterator<Item = f32>, mode: BlendMode) {
+/// pixel by its coverage `ks` (opacity × mask) in blend mode `mode` at layer Fill `fill`: below
+/// 100% the special eight ([`psblend::fill_is_special`]) blend a result pulled toward the mode's
+/// neutral colour, as a pixel layer does (#9501); for every other mode Fill is in `ks`.
+fn blend_back(backdrop: &mut [[f32; 4]], adjusted: &[[f32; 4]], ks: impl Iterator<Item = f32>, mode: BlendMode, fill: f32) {
     for ((b, a), k) in backdrop.iter_mut().zip(adjusted).zip(ks) {
         if k <= 0.0 {
             continue;
@@ -1074,7 +1076,7 @@ fn blend_back(backdrop: &mut [[f32; 4]], adjusted: &[[f32; 4]], ks: impl Iterato
         let s = [a[0], a[1], a[2]];
         // Normal is the source as it is (`blend_rgb` gives exactly that), and most adjustment
         // layers are Normal: no per-pixel mode dispatch for them.
-        let blended = if mode == BlendMode::Normal { s } else { blend::blend_rgb(mode, [b[0], b[1], b[2]], s) };
+        let blended = if mode == BlendMode::Normal { s } else { blend::blend_rgb_fill(mode, [b[0], b[1], b[2]], s, fill) };
         *b = [b[0] + (blended[0] - b[0]) * k, b[1] + (blended[1] - b[1]) * k, b[2] + (blended[2] - b[2]) * k, b[3]];
     }
 }
@@ -2014,9 +2016,13 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
                 }
             }
         } else {
+            // Fill below 100% in one of the special eight changes the blend itself and leaves
+            // coverage to Opacity, as for a pixel layer (psblend::composite_fill, #9501).
+            let (k, fill) =
+                if layer.fill_opacity < 1.0 && psblend::fill_is_special(layer.blend) { (layer.opacity, layer.fill_opacity) } else { (opacity, 1.0) };
             match &mv {
-                Some(m) => blend_back(&mut backdrop.px, &adjusted.px, m.iter().map(|v| opacity * v), layer.blend),
-                None => blend_back(&mut backdrop.px, &adjusted.px, std::iter::repeat(opacity), layer.blend),
+                Some(m) => blend_back(&mut backdrop.px, &adjusted.px, m.iter().map(|v| k * v), layer.blend, fill),
+                None => blend_back(&mut backdrop.px, &adjusted.px, std::iter::repeat(k), layer.blend, fill),
             }
         }
         recycle_buffer(adjusted);
diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs
index 38cc879b0..157667971 100644
--- a/crates/gpu/src/plan.rs
+++ b/crates/gpu/src/plan.rs
@@ -742,7 +742,7 @@ impl<'a> Planner<'a> {
             p.a = Some(before);
             p.b = Some(adjusted);
             p.mode = layer.blend;
-            p.opacity = opacity;
+            (p.opacity, p.fill) = adjustment_fill(layer, opacity);
             p.mask = self.mask_use(layer);
             return Ok(self.emit(p));
         }
@@ -999,7 +999,9 @@ impl<'a> Planner<'a> {
             p.a = Some(before);
             p.b = Some(adjusted);
             p.mode = layer.blend;
-            p.opacity = opacity;
+            // Clipped, an adjustment takes Fill a

#9481: fix(ui): new layer effects start from the Make Default values

crates/ui-egui/src/dialogs/layer_style.rs

diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs
index b65487c4f..7051c57be 100644
--- a/crates/ui-egui/src/layer_style.rs
+++ b/crates/ui-egui/src/layer_style.rs
@@ -64,6 +64,9 @@ pub const KINDS: &[(&str, &str)] = &[
 /// Effect kinds Photoshop offers several instances of (stored as `…Multi` lists in the PSD).
 const MULTI: &[&str] = &["stroke", "dropShadow", "innerShadow", "colorOverlay", "gradientOverlay"];
 
+/// Field holding the user defaults (Make Default) that new instances start from.
+const USER_DEFAULTS: &str = "userDefaults";
+
 fn multi(kind: &str) -> bool {
     MULTI.contains(&kind)
 }
@@ -76,9 +79,15 @@ fn defaults(kind: &str) -> Value {
     photocraft_engine::layer_style::effect_defaults(kind)
 }
 
-/// Factory params for a new instance, with the shared light angle filled in.
-fn fresh_params(kind: &str, light: f32) -> Value {
+/// Params for a new instance: the user default saved by Make Default over the factory
+/// defaults (#9477), with the shared light angle filled in.
+fn fresh_params(kind: &str, light: f32, user: Option<&Value>) -> Value {
     let mut p = defaults(kind);
+    if let (Some(o), Some(u)) = (p.as_object_mut(), user.and_then(Value::as_object)) {
+        for (k, v) in u {
+            o.insert(k.clone(), v.clone());
+        }
+    }
     if p.get("useGlobalLight").and_then(Value::as_bool) == Some(true) {
         p["angle"] = json!(light);
     }
@@ -461,7 +470,8 @@ fn next_id(f: &Map<String, Value>) -> String {
 fn add_instance(f: &mut Map<String, Value>, kind: &str) {
     let light = f.get("pendingGlobalLight").or_else(|| f.get("globalLight")).and_then(Value::as_f64).unwrap_or(120.0) as f32;
     let id = next_id(f);
-    let new = json!({"id": id, "kind": kind, "on": true, "params": fresh_params(kind, light)});
+    let user = f.get(USER_DEFAULTS).and_then(|d| d.get(kind));
+    let new = json!({"id": id, "kind": kind, "on": true, "params": fresh_params(kind, light, user)});
     if let Some(a) = f.get_mut("effects").and_then(Value::as_array_mut) {
         let pos = a.iter().rposition(|e| e.get("kind").and_then(Value::as_str) == Some(kind)).map_or(a.len(), |i| i + 1);
         a.insert(pos, new);
@@ -525,7 +535,13 @@ pub fn drag_shadow(f: &mut Map<String, Value>, start: bool, total: [f64; 2]) {
 }
 
 pub fn initial_fields(layer: &Layer, select: Option<&str>, light: f32) -> Map<String, Value> {
+    initial_fields_with(layer, select, light, Map::new())
+}
+
+/// The dialog's fields, with the user defaults (`kind` → params) new instances start from.
+pub fn initial_fields_with(layer: &Layer, select: Option<&str>, light: f32, user_defaults: Map<String, Value>) -> Map<String, Value> {
     let mut f = Map::new();
+    f.insert(USER_DEFAULTS.into(), Value::Object(user_defaults));
     f.insert("layer".into(), json!(layer.id.0));
     f.insert("globalLight".into(), json!(light));
     f.insert("preview".into(), json!(true));
@@ -581,7 +597,8 @@ pub fn open(app: &mut PhotocraftApp, select: Option<&str>) -> Option<u64> {
     let layer = st.doc.layer(st.active_layer?)?.clone();
     let light = st.doc.global_light.angle;
     let mode = st.doc.mode;
-    let mut f = initial_fields(&layer, select, light);
+    let user = app.session.presets.layer_defaults.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
+    let mut f = initial_fields_with(&layer, select, light, user);
     // Channels and Blend If list the document's own colour channels.
     set_blending_field

#9478: fix(text): vertical type sets roman letters upright, one per em, as Photoshop does (#9470)

crates/text/src/layout.rs, crates/text/src/psd.rs

diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs
index 4936b33c6..a4ec1d350 100644
--- a/crates/text/src/layout.rs
+++ b/crates/text/src/layout.rs
@@ -10,8 +10,10 @@
 //! into text space: a line becomes a column running top to bottom, and successive columns advance
 //! right to left. Within a column, CJK characters stay upright (centred on the column, placed with
 //! the font's vertical metrics: `VORG`, `vhea`/`vmtx`, else the ideographic em box) and use the
-//! font's vertical alternates (`vert`) for punctuation, brackets and the long-vowel mark; other
-//! text (Latin, digits) runs rotated 90° clockwise, Photoshop's default. Lines, clusters, carets
+//! font's vertical alternates (`vert`) for punctuation, brackets and the long-vowel mark. Other
+//! text (Latin, digits) stands upright one character per em too, as Photoshop's Vertical Type tool
+//! sets it, or runs rotated 90° clockwise where its style says so (`CharStyle::rotate_roman`,
+//! PSD `BaselineDirection` 2; #9470). Lines, clusters, carets
 //! and hit tests stay in line space; [`TextLayout::to_text`] / [`TextLayout::to_line`] convert.
 
 use std::borrow::Cow;
@@ -608,8 +610,30 @@ impl Layouter {
             let fallback: Vec<String> = fonts.fallback_stack().map(str::to_string).collect();
             let si0 = style_at(prange.start);
             let si_end = run_starts.partition_point(|&rs| rs < content_end).max(si0 + 1).min(out.styles.len());
-            // Shapes the paragraph, each tab widened by its letter spacing in `tab_spacing` (px).
-            let build = |lcx: &mut LayoutContext<RunBrush>, fcx: &mut parley::FontContext, tab_spacing: &[f32]| -> Layout<RunBrush> {
+            // Vertical type's upright roman characters (#9470), as byte ranges in `ptext` with their
+            // letter spacing (px) and em (px): each takes one em down the column, as Photoshop
+            // sets them, where shaping gives it its horizontal advance.
+            let mut roman: Vec<(Range<usize>, f32, f32, usize)> = Vec::new();
+            if vertical {
+                for (ri, st) in out.styles.iter().enumerate().take(si_end).skip(si0) {
+                    let rs = run_starts[ri];
+                    let (a, z) = (rs.max(prange.start), (rs + runs[ri].len).min(content_end));
+                    if a >= z || st.rotate_roman {
+                        continue;
+                    }
+                    let base = a - prange.start + prefix.len();
+                    let piece = ptext.get(base..base + (z - a)).unwrap_or("");
+                    let px = st.size_pt * k;
+                    for (i, ch) in piece.char_indices() {
+                        if upright_roman(ch) && !ch.is_whitespace() {
+                            roman.push(((base + i)..(base + i + ch.len_utf8()), st.tracking / 1000.0 * px, px, ri));
+                        }
+                    }
+                }
+            }
+            // Shapes the paragraph, each tab widened by its letter spacing in `tab_spacing` (px)
+            // and each upright roman character by its spacing in `roman_spacing` (px).
+            let build = |lcx: &mut LayoutContext<RunBrush>, fcx: &mut parley::FontContext, tab_spacing: &[f32], roman_spacing: &[f32]| -> Layout<RunBrush> {
                 let mut b = lcx.ranged_builder(fcx, &ptext, 1.0, false);
                 // Paragraph-start style as the default (covers the direction mark and empty
                 // paragraphs), then every run piece 

#9401: fix(format): a save on macOS keeps the file's creation time (#9388)

crates/automation/src/workspace.rs

diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs
index e8566b439..e3316a079 100644
--- a/crates/automation/src/workspace.rs
+++ b/crates/automation/src/workspace.rs
@@ -101,7 +101,10 @@ impl AuthorizedWorkspace {
                 // Best effort, and only when there is a file being replaced: a save still
                 // succeeds for a document whose Finder tag could not come along.
                 if let Ok(old) = root.dir.open(&relative) {
+                    let old = old.into_std();
                     let _ = photocraft_format::atomic::meta::copy_xattrs(old.as_fd(), file.as_fd());
+                    // The creation time too (#9388), through the same handles.
+                    let _ = photocraft_format::atomic::meta::copy_created_file(&old, &file);
                 }
             }
             // Falls back to a plain fsync on shares that refuse a full flush (#1336).
@@ -608,6 +611,22 @@ mod tests {
         assert!(temp_files(&inside).is_empty());
     }
 
+    /// #9401 review: replacing a file through the automation path keeps its creation time, the
+    /// same as a save through `atomic_write` does on macOS (#9388).
+    #[cfg(target_os = "macos")]
+    #[test]
+    fn write_keeps_the_replaced_files_creation_time() {
+        use std::os::macos::fs::FileTimesExt;
+        let (inside, _, workspace) = roots("created");
+        let path = inside.join("doc.psd");
+        workspace.write("doc.psd", b"original").unwrap();
+        let born = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_600_000_000);
+        std::fs::OpenOptions::new().write(true).open(&path).unwrap().set_times(std::fs::FileTimes::new().set_created(born)).unwrap();
+        workspace.write("doc.psd", b"replacement").unwrap();
+        assert_eq!(std::fs::read(&path).unwrap(), b"replacement");
+        assert_eq!(std::fs::metadata(&path).unwrap().created().unwrap(), born, "the creation time survives the automation write");
+    }
+
     /// #7095: replacing a file through the automation path keeps the old file's extended
     /// attributes, the same as a save through `atomic_write` does.
     #[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]

#9354: fix(ui): Full Screen Mode needs a document (#9349)

crates/ui-egui/src/lib.rs, crates/ui-egui/src/panels.rs, crates/ui-egui/src/view_cmds.rs

diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 4db3dc2a3..dbb9dd165 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -2297,6 +2297,7 @@ impl eframe::App for PhotocraftApp {
         }
         self.drain_control(ctx);
         native_menu::run(self, ctx);
+        view_cmds::leave_full_screen_without_document(self, ctx);
         // Switching tools commits, also between the Type and Type Mask tools.
         if self.ui.text_edit.as_ref().is_some_and(|ed| !self.ui.tool.is_type() || ed.mask != self.ui.tool.is_type_mask()) {
             type_tool::commit(self);
diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
index b6bd34d9d..aa35d04a4 100644
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -336,7 +336,14 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
                 {
                     let _ = crate::menus::invoke(app, &ctx, "select.editInQuickMaskMode", json!({}));
                 }
-                let sm = icons::button_with_icon_size_tip(ui, "app-window", bx, (bx * 0.52).round(), false, tl!("Change Screen Mode  (F)"), tool_tips);
+                // Greyed out while Cycle is unavailable (no document in Standard, #9349): its
+                // list would offer nothing else either.
+                let cycle_enabled = crate::view_cmds::is_enabled(app, "view.screenMode.cycle").unwrap_or(true);
+                let sm = ui
+                    .add_enabled_ui(cycle_enabled, |ui| {
+                        icons::button_with_icon_size_tip(ui, "app-window", bx, (bx * 0.52).round(), false, tl!("Change Screen Mode  (F)"), tool_tips)
+                    })
+                    .inner;
                 paint_toolbar_flyout_indicator(ui, &t, &sm);
 
                 // Right-click, or click and hold like the tool flyouts (#4444), lists the modes,
@@ -376,7 +383,8 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
                         ("view.screenMode.fullScreen", tl!("Full Screen Mode")),
                     ] {
                         let on = crate::view_cmds::checked(app, id).unwrap_or(false);
-                        if ui.add(egui::Button::selectable(on, label)).clicked() {
+                        let enabled = crate::view_cmds::is_enabled(app, id).unwrap_or(true);
+                        if ui.add_enabled(enabled, egui::Button::selectable(on, label)).clicked() {
                             let _ = crate::menus::invoke(app, &ctx, id, json!({}));
                             ui.close();
                         }
@@ -6048,11 +6056,54 @@ mod type_flyout_tests {
         assert!(ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::new("tool-flyout"))).is_none());
     }
 
+    /// #9354 review: with no document in Standard Screen Mode, Cycle is unavailable (#9349), so
+    /// Change Screen Mode is greyed out: a click sends no viewport command.
+    #[test]
+    fn screen_mode_button_is_inert_without_a_document() {
+        let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
+        app.run("file.new", serde_json::json!({"width": 64, "height": 48})).unwrap();
+        let ctx = egui::Context::default();
+        PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]);
+        frame(&mut app, &ctx, 0.0, vec![]);
+        frame(&mut app, &ctx, 0.1, vec![]);
+        let bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 };
+        // Found w

#9342: fix(io, engine): Embed Color Profile writes sRGB for an untagged document (#9325)

crates/engine/src/web_cmds.rs

diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs
index 3738aa6ca..5e2694325 100644
--- a/crates/engine/src/web_cmds.rs
+++ b/crates/engine/src/web_cmds.rs
@@ -900,7 +900,10 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u
     let (wdoc, sx, sy) = web_document(&doc, p, &st)?;
     let buf = photocraft_compose::flatten(&wdoc);
     let bw = wdoc.size.width as usize;
-    let icc = wdoc.icc_profile.as_deref().map(|v| v.as_slice());
+    // An untagged document is treated as sRGB (see [`web_document`]); with Embed Color Profile
+    // on, the file says so, as Photoshop's does (#9325).
+    let srgb = (st.embed_icc && wdoc.icc_profile.is_none()).then(|| photocraft_cms::Builtin::Srgb.profile().to_bytes().to_vec());
+    let icc = wdoc.icc_profile.as_deref().map(|v| v.as_slice()).or(srgb.as_deref());
     let xmp = web_xmp(&doc, st.metadata);
     let exif = web_exif(&doc, st.metadata);
     let dpi = wdoc.resolution_dpi;

#9339: fix(export): embed requested sRGB profile for untagged sources (#9325)

crates/ui-egui/src/dialogs/export_dialog.rs

diff --git a/crates/ui-egui/src/export_dialog.rs b/crates/ui-egui/src/export_dialog.rs
index 29ee74b1a..06490f096 100644
--- a/crates/ui-egui/src/export_dialog.rs
+++ b/crates/ui-egui/src/export_dialog.rs
@@ -6,7 +6,8 @@ use std::sync::Arc;
 #[cfg(not(target_arch = "wasm32"))]
 use std::sync::Mutex;
 
-use photocraft_doc::Document;
+use photocraft_cms::Profile;
+use photocraft_doc::{ColorMode, Document};
 use serde_json::{Map, Value, json};
 
 use photocraft_engine::export_preset_cmds::{DEFAULT_RESAMPLE, RESAMPLE, export_resample};
@@ -259,6 +260,14 @@ fn source_document(app: &PhotocraftApp, f: &Map<String, Value>) -> Result<Arc<Do
     source_document_from(app.session.active().ok_or("no document")?.doc.clone(), f)
 }
 
+fn effective_source_profile(app: &PhotocraftApp, doc: &Document) -> Arc<Profile> {
+    doc.icc_profile
+        .as_ref()
+        .and_then(|bytes| photocraft_engine::color_cmds::profile_from_bytes(bytes).ok())
+        .filter(|profile| profile.color_space == photocraft_engine::color_cmds::mode_space(doc.mode))
+        .unwrap_or_else(|| app.session.color.working(doc.mode))
+}
+
 fn s(f: &Map<String, Value>, k: &str) -> String {
     f.get(k).and_then(Value::as_str).unwrap_or_default().to_string()
 }
@@ -285,11 +294,16 @@ fn nearest(f: &Map<String, Value>) -> bool {
 
 /// The document as exported: scaled (engine Image Size, with the dialog's Resample method) and
 /// flattened over white when transparency is off or the format has no alpha.
-fn export_document(doc: &Document, f: &Map<String, Value>, max_side: Option<u32>) -> Result<Document, String> {
+fn export_document(doc: &Document, f: &Map<String, Value>, max_side: Option<u32>, source_profile: &Profile) -> Result<Document, String> {
     let resample = resample_method(f, max_side)?;
+    let srgb = s(f, "profile") == "srgb";
+    let mut copy = doc.clone();
+    if srgb {
+        copy.icc_profile = Some(source_profile.to_bytes());
+    }
     let mut session = photocraft_engine::Session::new();
-    session.add_document(doc.clone(), None);
-    if s(f, "profile") == "srgb" && doc.icc_profile.is_some() {
+    session.add_document(copy, None);
+    if srgb && (doc.mode != ColorMode::Rgb || !source_profile.same_bytes(photocraft_cms::Builtin::Srgb.profile())) {
         session.execute("edit.convertToProfile", json!({"profile": "srgb", "intent": "perceptual"})).map_err(|e| e.to_string())?;
     }
     let (w, h) = export_pixels(f64::from(doc.size.width), f64::from(doc.size.height), f, max_side)?;
@@ -441,8 +455,8 @@ fn settings(f: &Map<String, Value>) -> ExportSettings {
 
 /// The preview and size estimate share a single reduced document. Resizing every layer twice is
 /// especially expensive for layered PSDs, even when the canvas itself is small.
-fn preview_proxy(doc: &Document, f: &Map<String, Value>) -> Result<Document, String> {
-    export_document(doc, f, Some(if s_fmt(f) == "ico" { 256 } else { 512 }))
+fn preview_proxy(doc: &Document, f: &Map<String, Value>, source_profile: &Profile) -> Result<Document, String> {
+    export_document(doc, f, Some(if s_fmt(f) == "ico" { 256 } else { 512 }), source_profile)
 }
 
 fn scaled_estimate(bytes: usize, doc: &Document, proxy: &Document, f: &Map<String, Value>) -> u64 {
@@ -476,8 +490,8 @@ fn preview_pixels(proxy: &Document, estimate: Option<u64>) -> PreviewPixels {
     PreviewPixels { estimate, width: img.width as usize, height: img.height as usize, rgba: img.pixels }
 }
 
-fn preview_sync(app: &PhotocraftApp, d

#9282: feat(ui): Preset menus for Levels, Exposure, Black & White and Channel Mixer

crates/ui-egui/src/adjust_dialog.rs, crates/ui-egui/src/adjust_editors.rs, crates/ui-egui/src/lib.rs, docs/ui-parity.md

diff --git a/crates/ui-egui/src/adjust_dialog.rs b/crates/ui-egui/src/adjust_dialog.rs
index 18e32e486..55826aea9 100644
--- a/crates/ui-egui/src/adjust_dialog.rs
+++ b/crates/ui-egui/src/adjust_dialog.rs
@@ -802,6 +802,54 @@ mod tests {
         assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1);
     }
 
+    /// #9263, #9265, #9268, #9270: Levels, Exposure, Black & White and Channel Mixer open on the
+    /// Default preset; choosing another loads its settings (the preview follows) and an edit by
+    /// hand reads Custom.
+    #[test]
+    fn adjustment_preset_menus_load_presets_and_read_custom_after_an_edit() {
+        use egui_kittest::kittest::{NodeT, Queryable};
+        for (kind, label, key) in [
+            ("levels", "Increase Contrast 2", "inBlack"),
+            ("exposure", "Plus 1.0", "exposure"),
+            ("blackWhite", "Red Filter", "reds"),
+            ("channelMixer", "Black & White with Red Filter (RGB)", "monochrome"),
+        ] {
+            let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app_with_image());
+            PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
+            let id = open(h.state_mut(), &format!("image.adjustments.{kind}")).unwrap();
+            h.run_steps(4);
+            let preset = |h: &Harness<'_, PhotocraftApp>| {
+                h.query_all_by_role(egui::accesskit::Role::ComboBox).next().and_then(|n| n.accesskit_node().value().map(|s| s.to_string()))
+            };
+            assert_eq!(preset(&h).as_deref(), Some("Default"), "{kind} opens on Default");
+            h.query_all_by_role(egui::accesskit::Role::ComboBox).next().unwrap().click();
+            h.run_steps(2);
+            h.get_by_label(label).click();
+            h.run_steps(3);
+            assert_eq!(preset(&h).as_deref(), Some(label), "{kind}: the menu shows the preset");
+            let want = crate::adjust_presets::presets(kind, false)
+                .iter()
+                .find(|p| p.label == label)
+                .and_then(|p| crate::adjust_presets::settings(kind, p, false))
+                .unwrap();
+            let fields = &h.state().ui.dialogs[0].fields;
+            for (k, v) in want.as_object().expect("preset settings") {
+                assert_eq!(fields[k], *v, "{kind}: the preset's `{k}` is loaded");
+            }
+            assert_eq!(fields[key], want[key]);
+            let edit = match kind {
+                "levels" => ("gamma", json!(1.37)),
+                "exposure" => ("offset", json!(0.05)),
+                "blackWhite" => ("blues", json!(33)),
+                _ => ("gray", json!([90, 10, 0, 0])),
+            };
+            h.state_mut().ui.dialog_mut(id).unwrap().fields.insert(edit.0.into(), edit.1);
+            h.run_steps(3);
+            assert_eq!(preset(&h).as_deref(), Some("Custom"), "{kind}: an edit is Custom");
+            crate::dialogs::confirm(h.state_mut(), id).unwrap();
+        }
+    }
+
     #[test]
     fn curves_pointer_drag_previews_until_confirm_and_cancel_preserves_document() {
         for confirm in [false, true] {
diff --git a/crates/ui-egui/src/adjust_editors.rs b/crates/ui-egui/src/adjust_editors.rs
index 43217a10f..566309228 100644
--- a/crates/ui-egui/src/adjust_editors.rs
+++ b/crates/ui-egui/src/adjust_editors.rs
@@ -112,6 +112,43 @@ pub fn editor(ui: &mut egui::Ui, kind: &str, v: &mut Value, cx: &E

#9019: fix(xtask): reject unknown performance benchmark selections

xtask/src/perf.rs

diff --git a/xtask/src/perf.rs b/xtask/src/perf.rs
index c1890d1cb..f1aa01671 100644
--- a/xtask/src/perf.rs
+++ b/xtask/src/perf.rs
@@ -670,6 +670,11 @@ pub fn run(root: &Path, rest: &[&str]) -> Result<(), String> {
     let o = parse_opts(rest)?;
     let text = std::fs::read_to_string(root.join(BUDGETS)).map_err(|e| format!("read {BUDGETS}: {e}"))?;
     let budgets = parse_budgets(&text)?;
+    for name in &o.benches {
+        if !budgets.bench.iter().any(|b| &b.name == name) {
+            return Err(format!("perf: unknown bench `{name}` (see {BUDGETS})"));
+        }
+    }
     let mode = if o.quick { "quick" } else { "full" };
     let threshold = o.threshold.unwrap_or(budgets.settings.regression_pct);
     let target = target_dir()?;
@@ -1118,6 +1123,26 @@ not_measurable = "needs a frame harness"
         assert_eq!(counts(&evaluate(&b, &reports, None, 15.0, true)), (1, 1, 0, 1), "the row the bench lacks counts as failing");
     }
 
+    #[test]
+    fn unknown_bench_is_rejected_before_updating_the_baseline() {
+        let root = std::env::temp_dir().join(format!("photocraft-perf-unknown-bench-{}", std::process::id()));
+        std::fs::create_dir(&root).unwrap();
+        std::fs::create_dir(root.join("perf")).unwrap();
+        std::fs::write(root.join(BUDGETS), "[[bench]]\nname = \"known\"\npackage = \"unused\"\nrequires = \"missing.rs\"\n").unwrap();
+        let baseline = "{\"schema\":1,\"sentinel\":true}";
+        std::fs::write(root.join(BASELINE), baseline).unwrap();
+        let results = [
+            run(&root, &["--bench", "unknown", "--reuse", "--update-baseline"]),
+            run(&root, &["--bench", "known", "--bench", "unknown", "--quick", "--reuse", "--update-baseline"]),
+        ];
+        let after = std::fs::read_to_string(root.join(BASELINE)).unwrap();
+        std::fs::remove_dir_all(root).unwrap();
+        for result in results {
+            assert_eq!(result, Err(format!("perf: unknown bench `unknown` (see {BUDGETS})")));
+        }
+        assert_eq!(after, baseline, "an unknown bench must not update the baseline");
+    }
+
     #[test]
     fn options() {
         assert!(parse_opts(&["--bogus"]).is_err());

#8986: Path tools: edit the saved path selected in the Paths panel (#8478)

crates/ui-egui/src/tools/direct_select.rs, crates/ui-egui/src/vector_ui.rs

diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs
index 56036a852..2ab48a671 100644
--- a/crates/ui-egui/src/vector_ui.rs
+++ b/crates/ui-egui/src/vector_ui.rs
@@ -888,6 +888,8 @@ enum PathTarget {
     Shape(u64),
     /// The active layer's vector mask, when its Layers thumbnail is targeted (#196).
     VectorMask(u64),
+    /// The saved path selected in the Paths panel, by name (#8478).
+    Saved(String),
     Work,
 }
 
@@ -923,8 +925,8 @@ pub(crate) fn delete_key_deletes_work_path(app: &PhotocraftApp) -> bool {
         && matches!(target_path(app), Some((PathTarget::Work, _)))
 }
 
-/// The path Path Selection edits: the targeted vector mask, the active shape layer's path, else
-/// the work path.
+/// The path Path Selection edits: the targeted vector mask, the active shape layer's path, the
+/// saved path selected in the Paths panel (#8478), else the work path.
 fn target_path(app: &PhotocraftApp) -> Option<(PathTarget, Path)> {
     if let Some((id, p)) = targeted_vector_mask(app) {
         return Some((PathTarget::VectorMask(id), p));
@@ -932,6 +934,11 @@ fn target_path(app: &PhotocraftApp) -> Option<(PathTarget, Path)> {
     if let Some((id, p)) = active_shape_path(app) {
         return Some((PathTarget::Shape(id), p));
     }
+    if let Some(name) = app.ui.selected_path.as_deref()
+        && let Some((_, p)) = selected_saved_path_index(app)
+    {
+        return Some((PathTarget::Saved(name.to_string()), p));
+    }
     app.session.active()?.doc.work_path.clone().map(|p| (PathTarget::Work, p))
 }
 
@@ -945,6 +952,7 @@ pub(crate) fn free_transform_path(app: &PhotocraftApp) -> Option<(Value, Path)>
     path.control_bounds()?;
     let params = match target {
         PathTarget::Shape(id) | PathTarget::VectorMask(id) => json!({"name": "layer", "layer": id}),
+        PathTarget::Saved(name) => json!({"name": name}),
         PathTarget::Work => json!({"name": "work"}),
     };
     Some((params, path))
@@ -1049,6 +1057,10 @@ pub fn path_selection_finish(app: &mut PhotocraftApp, start: [f64; 2], end: [f64
             let moved = path.transform(&Affine::translate(dx.round(), dy.round()));
             app.run("layer.vectorMask.edit", json!({"layer": id, "path": photocraft_engine::vector_cmds::path_json(&moved)}))
         }
+        PathTarget::Saved(name) => {
+            let moved = path.transform(&Affine::translate(dx.round(), dy.round()));
+            app.run("path.set", json!({"name": name, "path": photocraft_engine::vector_cmds::path_json(&moved)}))
+        }
         PathTarget::Work => {
             let moved = path.transform(&Affine::translate(dx.round(), dy.round()));
             app.run("path.set", json!({"name": "work", "path": photocraft_engine::vector_cmds::path_json(&moved)}))
@@ -1165,6 +1177,13 @@ pub(crate) fn selected_saved_path(app: &PhotocraftApp) -> Option<&Path> {
     app.session.active()?.doc.paths.iter().find(|p| p.name == sel).map(|p| &p.path)
 }
 
+/// [`selected_saved_path`] with its index in the document's `paths`: the saved path Path
+/// Selection and Direct Selection edit (#8478).
+pub(crate) fn selected_saved_path_index(app: &PhotocraftApp) -> Option<(usize, Path)> {
+    let sel = app.ui.selected_path.as_deref().filter(|s| !matches!(*s, "work" | "layer"))?;
+    app.session.active()?.doc.paths.iter().enumerate().find(|(_, p)| p.name == sel).map(|(i, p)| (i, p.path.clone()))
+}
+
 /// Work path / active shape path / selected saved path outlines, anch

#8985: Color panel and Color Picker: clip the round cursor to the colour field

crates/ui-egui/src/dialogs/color_picker_ui.rs, crates/ui-egui/src/panels.rs

diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
index b6bd34d9d..7c380130b 100644
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -4191,8 +4191,10 @@ pub(crate) fn color_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
         }
         let (kx, ky) = if brightness { (hsva.h, 1.0 - hsva.s) } else { (hsva.s, 1.0 - hsva.v) };
         let knob = pos2(field.left() + kx * field.width(), field.top() + ky * field.height());
-        ui.painter().circle_stroke(knob, 5.0, Stroke::new(1.5, Color32::WHITE));
-        ui.painter().circle_stroke(knob, 6.5, Stroke::new(1.0, Color32::from_black_alpha(160)));
+        // At the extremes the knob sits on the edge: clip it to the field (#8968).
+        let painter = ui.painter().with_clip_rect(field);
+        painter.circle_stroke(knob, 5.0, Stroke::new(1.5, Color32::WHITE));
+        painter.circle_stroke(knob, 6.5, Stroke::new(1.0, Color32::from_black_alpha(160)));
         // The strip: hue (red at both ends, as Photoshop's) or brightness (the colour down to black).
         let (strip, sresp) = ui.allocate_exact_size(vec2(strip_w, h), Sense::click_and_drag());
         let strip_at = |f: f32| {
@@ -5237,6 +5239,36 @@ mod color_tests {
         assert!(h.query_by_role(egui::accesskit::Role::TextInput).is_some(), "hex field stays visible");
     }
 
+    /// #8968: the round cursor sits on the field's edge at the extremes and must not paint past it
+    /// (over the hue strip or the chips).
+    #[test]
+    fn color_field_cursor_stays_inside_the_field() {
+        let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
+        // Black is s = 0, v = 0: the bottom-left corner.
+        app.session.tools.foreground = [0.0, 0.0, 0.0, 1.0];
+        let h = field_harness(app);
+        let mut field = None;
+        let mut circles = Vec::new();
+        for clipped in &h.output().shapes {
+            match &clipped.shape {
+                // The saturation/brightness field is the 17 x 17 mesh; the hue strip is a 25 x 25 grid.
+                egui::Shape::Mesh(m) if m.vertices.len() == 17 * 17 => field = Some(m.calc_bounds()),
+                egui::Shape::Circle(c) => circles.push((c.center, c.radius, clipped.clip_rect)),
+                _ => {}
+            }
+        }
+        let field = field.expect("colour field drawn");
+        let expected_center = pos2(field.left(), field.bottom());
+        let rings: Vec<_> =
+            circles.into_iter().filter(|(center, radius, _)| center.distance(expected_center) < 0.01 && (*radius == 5.0 || *radius == 6.5)).collect();
+        assert!(rings.iter().any(|(_, radius, _)| *radius == 5.0), "inner ring sits at the field's bottom-left corner");
+        assert!(rings.iter().any(|(_, radius, _)| *radius == 6.5), "outer ring sits at the field's bottom-left corner");
+        for (center, radius, clip) in rings {
+            let visible = egui::Rect::from_center_size(center, vec2(2.0 * radius, 2.0 * radius)).intersect(clip);
+            assert!(field.contains_rect(visible), "cursor ring {visible:?} leaves the field {field:?}");
+        }
+    }
+
     fn field_harness(app: PhotocraftApp) -> egui_kittest::Harness<'static, PhotocraftApp> {
         // 60 fps steps, so two clicks a frame apart are a double-click.
         let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 200.0)).with_step_dt(1.0 / 60.0).build_ui_state(

#8905: fix(plugins): bound a run's total plug-in memory, not just each instance

crates/plugins/tests/abi.rs

diff --git a/crates/plugins/tests/abi.rs b/crates/plugins/tests/abi.rs
index a75eedd88..d95f0edd9 100644
--- a/crates/plugins/tests/abi.rs
+++ b/crates/plugins/tests/abi.rs
@@ -317,3 +317,36 @@ fn folder_loading_skips_bad_files() {
     assert!(photocraft_plugins::registry::load_file(&dir, Limits::default()).is_err(), "a directory is not a module");
     std::fs::remove_dir_all(&dir).ok();
 }
+
+/// #8603: one run on 16 workers must not hold 16 instances at the 512 MiB cap (8 GiB). With the
+/// default limits it holds at most four (2 GiB); a smaller per-instance cap lets every worker
+/// run, and a budget under one cap still runs one band at a time rather than none.
+#[test]
+fn parallel_bands_fit_the_total_memory_budget() {
+    let d = Limits::default();
+    assert_eq!(d.parallel_bands(16), 4);
+    assert!(d.parallel_bands(64) * d.max_memory_bytes <= d.max_total_memory_bytes);
+    assert_eq!(d.parallel_bands(2), 2, "fewer workers than the budget allows");
+    let small = Limits { max_memory_bytes: 64 << 20, ..Limits::default() };
+    assert_eq!(small.parallel_bands(16), 16, "small instances keep every worker busy");
+    let tight = Limits { max_total_memory_bytes: 1 << 20, ..Limits::default() };
+    assert_eq!(tight.parallel_bands(16), 1, "at least one band runs");
+    assert_eq!(d.parallel_bands(0), 1);
+}
+
+/// #8603: a run whose memory budget holds a single instance still covers every band on a
+/// many-worker pool, one band at a time, with the same pixels as a run of parallel bands.
+#[test]
+fn one_band_at_a_time_matches_parallel_bands() {
+    let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().unwrap();
+    let s = surface(PixelFormat::RGBA8, 600, 300);
+    let canvas = Rect::new(0, 0, 600, 300);
+    let run = |limits: Limits| {
+        let p = Plugin::load(INVERT, limits).unwrap();
+        pool.install(|| p.apply(&s, canvas, None, &json!({}))).unwrap()
+    };
+    let parallel = run(Limits { band_bytes: 1, ..Limits::default() });
+    let serial = run(Limits { band_bytes: 1, max_total_memory_bytes: 512 << 20, ..Limits::default() });
+    assert_eq!(serial, parallel);
+    assert_ne!(serial, s, "the filter ran");
+}

#8897: fix(io): read linked smart objects for PSD export as regular files only

crates/io/src/psd_export.rs

diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs
index fd07adde5..e8ae58378 100644
--- a/crates/io/src/psd_export.rs
+++ b/crates/io/src/psd_export.rs
@@ -988,7 +988,9 @@ impl Ex {
                         return Err(format!("the linked file {path} is not read for this export"));
                     }
                     let file = crate::doc_path::resolve(path, self.document.as_deref()).map_err(|e| format!("the linked file {path} is not opened: {e}"))?;
-                    let bytes = std::fs::read(file).map_err(|e| format!("the linked file {path} can't be read: {e}"))?;
+                    // Regular files only, in bounded reads: a link to a FIFO would block the save
+                    // and one to a device such as `/dev/zero` would never end (#8601).
+                    let bytes = photocraft_format::read_file(&file).map_err(|e| format!("the linked file {path} can't be read: {e}"))?;
                     let name = path.rsplit(['/', '\\']).next().unwrap_or(path).to_string();
                     self.warnings.push(format!("the linked smart object {name} was embedded (PSD export keeps no external links)"));
                     self.embed(sm.contents_id, &name, &bytes)
@@ -2218,4 +2220,39 @@ mod tests {
         assert_eq!(back.channels[0].name, "Alpha 1");
         assert!((back.channels[0].opacity - 0.5).abs() < 1e-6);
     }
+
+    /// A linked smart object whose path names a FIFO is not read (#8601): opening a FIFO for
+    /// reading blocks until a writer appears, and a device such as `/dev/zero` never ends, so the
+    /// export would hang or run out of memory. The export finishes and writes the layer as pixels,
+    /// saying why. The export runs on its own thread so that a regression fails here instead of
+    /// hanging the test run.
+    #[cfg(unix)]
+    #[test]
+    fn a_linked_fifo_is_not_read() {
+        let dir = std::env::temp_dir().join(format!("photocraft-linked-fifo-{}", std::process::id()));
+        std::fs::create_dir_all(&dir).unwrap();
+        let fifo = dir.join("link.png");
+        let _ = std::fs::remove_file(&fifo);
+        assert!(std::process::Command::new("mkfifo").arg(&fifo).status().unwrap().success(), "mkfifo failed");
+        let mut doc = document(8, 6);
+        let smart = photocraft_doc::SmartObject {
+            contents_id: SmartContentsId::fresh(),
+            source: photocraft_doc::SmartSource::Linked { path: fifo.to_string_lossy().into_owned() },
+            transform: photocraft_geom::Affine::IDENTITY,
+            smart_filters: vec![],
+            cache: Some(Surface::new(doc.pixel_format())),
+            psd_raw: None,
+            filters_enabled: true,
+            filter_mask: None,
+            warp: None,
+            stack_mode: None,
+            perspective: None,
+        };
+        doc.layers.push(Layer::new("Linked", LayerContent::Smart(smart)));
+        let (tx, rx) = std::sync::mpsc::channel();
+        std::thread::spawn(move || tx.send(document_to_psd_with(&doc, &PsdExportOptions::default()).1));
+        let warnings = rx.recv_timeout(std::time::Duration::from_secs(30)).expect("the export blocked reading the FIFO");
+        let _ = std::fs::remove_dir_all(&dir);
+        assert!(warnings.iter().any(|w| w.contains("smart object written as pixels") && w.contains("not a regular file")), "{warnings:?}");
+    }
 }

#8892: ui: larger Align icons and overflow button; Save for Web keeps its width on an invalid Matte

crates/ui-egui/src/file_ui.rs, crates/ui-egui/src/tools/align_bar.rs, crates/ui-egui/src/widgets/tab_strip.rs

diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs
index e014ca9e3..6041f6a06 100644
--- a/crates/ui-egui/src/file_ui.rs
+++ b/crates/ui-egui/src/file_ui.rs
@@ -331,10 +331,9 @@ fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option<
                 None => (px.iter().flat_map(|q| q.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect::<Vec<u8>>(), None),
                 Some(p) => match web_preview::optimized(ui, key, &format!("{}|{rev}|{frame:?}|{size_sig}", doc.id.0), p, &px, w, h) {
                     Ok((preview, bytes, colors, ext)) => (preview, Some(((bytes as f64 * ratio) as usize, colors, ext))),
-                    Err(error) => {
-                        ui.label(error);
-                        (vec![0; (w * h * 4) as usize], None)
-                    }
+                    // The error shows on the row above the panes; a label here sat beside the pane
+                    // and widened the dialog (#8830).
+                    Err(_) => (vec![0; (w * h * 4) as usize], None),
                 },
             };
             let img = egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &rgba);

#8347: Type: Roman Hanging Punctuation (trailing marks hang, don't steer the alignment)

crates/engine/src/cmd/type_cmds.rs, crates/text/src/layout.rs, crates/text/src/psd.rs, crates/ui-egui/src/type_tool.rs, docs/scorecard.md, docs/ui-parity.md

diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs
index 4936b33c6..bc0729f14 100644
--- a/crates/text/src/layout.rs
+++ b/crates/text/src/layout.rs
@@ -457,6 +457,13 @@ struct KernSlot {
     size: f32,
     rtl: bool,
     blank: bool,
+    /// Hangs at the line's end when the paragraph has hanging punctuation on, weighted: 1.0
+    /// for the marks that hang fully outside the margin (a period, a comma, a hyphen, a
+    /// quote, an apostrophe), 0.5 for those that hang half (a colon, a semicolon, an
+    /// ellipsis, a dash); 0.0 marks no hang. Photoshop's Roman Hanging Punctuation list,
+    /// from the Help: "quotes, apostrophes, hyphens, periods and commas hang 100%;
+    /// colons, semicolons, ellipses and dashes hang 50%; at both margins".
+    hang_w: f32,
     /// Advance along the line (px, horizontal scale included).
     advance: f32,
     /// What horizontal scale adds to the shaped advance (px; negative below 100%).
@@ -773,6 +780,154 @@ impl Layouter {
                 }
                 let m = *line.metrics();
                 let LineMetrics { ascent, descent, leading } = line_metrics(&line, &out.styles, &ps, k, first_style, vertical);
+                // Hang-row detection needs the byte mapping the rest of the body uses.
+                let map = |o: usize| (prange.start + o.saturating_sub(prefix.len())).min(content_end);
+                // Hanging punctuation: a row of nothing but marks was pushed here because they
+                // didn't fit the line above; with the paragraph's hanging on, step them back
+                // onto that line so they hang past the box edge (Photoshop's Roman Hanging
+                // Punctuation): a centered line keeps its centering, a left line keeps filling
+                // the box. Horizontal lines only (vertical type has Burasagari instead) and no
+                // RTL marks; the text between the line above and the marks stays contiguous, so
+                // carets and selection keep their rows. The marks keep their style's manual
+                // kerning but drop optical pair kerning and underline decorations along the way.
+                let hang_row = ps.hanging
+                    && !vertical
+                    // Area type plans its rows and slots ahead of the layout; merging a mark
+                    // row back would leave a slot for a row that no longer exists, so hanging
+                    // applies to point and box lines only there.
+                    && area.is_none()
+                    // An empty row (two forced breaks in a row) has no items: `all` would read
+                    // it as a hanging run and merge it into the line above, dropping the blank
+                    // row.
+                    && line.items().next().is_some()
+                    && line.items().all(|it| match it {
+                        PositionedLayoutItem::GlyphRun(gr) => gr.run().visual_clusters().all(|c| {
+                            !c.is_rtl()
+                                && !c.is_space_or_nbsp()
+                                && c.text_range().end > prefix.len()
+                                && hang_cluster(ptext.get(c.text_range().clone()).unwrap_or(""))
+                        }),
+                        _ => false,
+                    });
+                if hang_row
+                    && let Some(prev) = out.lines.last()
+                    && prev.paragraph == pi
+                {
+                    // The

#8074: Transform: remember selected interpolation (#8067)

crates/ui-egui/src/prefs_ui.rs, crates/ui-egui/src/state.rs, crates/ui-egui/src/transform_tool.rs

diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs
index b9ed13083..18705ec21 100644
--- a/crates/ui-egui/src/prefs_ui.rs
+++ b/crates/ui-egui/src/prefs_ui.rs
@@ -3610,15 +3610,17 @@ mod tests {
         tick(&mut app, &ctx);
         app.ui.tool_options.move_auto_select = false;
         app.ui.tool_options.feather = 12.0;
+        app.ui.tool_options.transform_interpolation = Some(photocraft_engine::prefs::Interpolation::Nearest);
         crate::tool_memory::persist(&mut app, &ctx);
         tick(&mut app, &ctx);
         // Only what differs from the defaults is stored.
-        assert_eq!(stored(&store)["toolOptions"], json!({"move_auto_select": false, "feather": 12.0}));
+        assert_eq!(stored(&store)["toolOptions"], json!({"move_auto_select": false, "feather": 12.0, "transform_interpolation": "nearestNeighbor"}));
         let saved = store.lock().unwrap().clone().unwrap();
         let (mut app2, _) = app_with_saved(Some(saved));
         tick(&mut app2, &ctx);
         assert!(!app2.ui.tool_options.move_auto_select);
         assert_eq!(app2.ui.tool_options.feather, 12.0);
+        assert_eq!(app2.ui.tool_options.transform_interpolation, Some(photocraft_engine::prefs::Interpolation::Nearest));
         assert_eq!(app2.ui.tool_options.tolerance, crate::state::ToolOptions::default().tolerance);
         // A hand-edited file: unreadable fields keep their defaults, the good ones still load.
         for bad in [json!("junk"), json!({"move_auto_select": "no", "feather": 1e300, "tolerance": 5.0, "unknown": 1})] {
diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs
index 313186157..77ebcd5b1 100644
--- a/crates/ui-egui/src/state.rs
+++ b/crates/ui-egui/src/state.rs
@@ -691,6 +691,9 @@ pub struct ToolOptions {
     pub move_target: String,
     #[serde(default)]
     pub move_show_transform: bool,
+    /// Last interpolation selected in the Transform options bar; unset follows General's default.
+    #[serde(default)]
+    pub transform_interpolation: Option<photocraft_engine::prefs::Interpolation>,
     /// Crop options bar: aspect ratio ("" = Ratio/unconstrained, "w:h", or "original") and
     /// Delete Cropped Pixels.
     #[serde(default)]
@@ -954,6 +957,7 @@ impl Default for ToolOptions {
             move_auto_select: true,
             move_target: default_move_target(),
             move_show_transform: false,
+            transform_interpolation: None,
             crop_ratio: String::new(),
             crop_custom_ratio_width: 0.0,
             crop_custom_ratio_height: 0.0,
diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs
index 65a5c7014..f63f4be0b 100644
--- a/crates/ui-egui/src/transform_tool.rs
+++ b/crates/ui-egui/src/transform_tool.rs
@@ -187,6 +187,26 @@ fn corners(r: [f64; 4]) -> [[f64; 2]; 4] {
     [[r[0], r[1]], [r[2], r[1]], [r[2], r[3]], [r[0], r[3]]]
 }
 
+/// The transform choices supported by the options bar, using its remembered choice or the general default.
+pub(crate) fn default_interpolation(app: &PhotocraftApp) -> &'static str {
+    use photocraft_engine::prefs::Interpolation;
+    match app.ui.tool_options.transform_interpolation.unwrap_or(app.session.prefs().general.image_interpolation) {
+        Interpolation::Nearest => "nearest",
+        Interpolation::Bilinear => "bilinear",
+        _ => "bicubic",
+    }
+}
+
+fn interpolation_preference(interpolation: &str) -> Option<photocraft_engine::prefs::Interpola

#8039: Compose: Extend Softer outer glows past Size (#7899)

crates/compose/src/effects.rs, crates/gpu/src/fx.rs

diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs
index cbdf7f35a..1c751225b 100644
--- a/crates/compose/src/effects.rs
+++ b/crates/compose/src/effects.rs
@@ -19,7 +19,8 @@
 //! Shapes come from the layer's alpha (after its mask). A stroke's outer edge, and precise glows /
 //! chiselled bevels, use an exact Euclidean transform: a 5 × 5 chamfer's level sets are polygons,
 //! which a wide stroke shows as flat facets (#3646). Inside stroke edges and spread / choke keep the
-//! cheaper chamfer. Soft falloffs are two box blurs (a tent).
+//! cheaper chamfer. Soft falloffs are two box blurs (a tent); Softer outer glows retain a short
+//! fading tail beyond Size.
 
 use photocraft_color::blend::BlendMode;
 use photocraft_doc::Pattern;
@@ -62,7 +63,14 @@ pub fn margin(layer: &Layer) -> i32 {
     for e in layer.effects.items.iter().filter(|e| e.enabled()) {
         let r = match e {
             Effect::DropShadow(s) | Effect::InnerShadow(s) => s.distance + s.size,
-            Effect::OuterGlow(g) | Effect::InnerGlow(g) => g.size,
+            Effect::OuterGlow(g) => {
+                if g.technique == GlowTechnique::Softer {
+                    g.size + softer_glow_tail_width(g.size)
+                } else {
+                    g.size
+                }
+            }
+            Effect::InnerGlow(g) => g.size,
             Effect::Stroke(s) => s.size,
             Effect::Satin(s) => satin_distance(s) + s.size,
             Effect::BevelEmboss(b) => b.size + b.soften,
@@ -1192,6 +1200,9 @@ fn glow_map(shape: &Map, g: &Glow, inner: bool, origin: (i32, i32)) -> Map {
             let (r, bw) = spread_split(g.size, g.spread);
             let mut m = dilate(&src, r);
             blur(&mut m, bw);
+            if !inner {
+                add_softer_glow_tail(&mut m, &src, g.size, g.range);
+            }
             m
         }
     };
@@ -1463,6 +1474,39 @@ fn tent_fast(m: &mut Map, w: f32) {
     m.v = transpose_map(&b, mh, mw);
 }
 
+/// Width of the added fading tail on a Softer outer glow, capped so large settings stay local.
+pub fn softer_glow_tail_width(size: f32) -> f32 {
+    if size.is_finite() && size > 0.0 { (size * 0.1).clamp(1.0, 6.0) } else { 0.0 }
+}
+
+/// Raw coverage at the Size boundary, scaled through the glow's Range to a subtle one-percent tail.
+pub fn softer_glow_tail_amplitude(range: f32) -> f32 {
+    let range = if range.is_finite() { range.clamp(0.01, 1.0) } else { 1.0 };
+    0.005 * range
+}
+
+fn add_softer_glow_tail(map: &mut Map, shape: &Map, size: f32, range: f32) {
+    let width = softer_glow_tail_width(size);
+    if width == 0.0 {
+        return;
+    }
+    let amp = softer_glow_tail_amplitude(range);
+    let distance = dist_outside(shape);
+    for (coverage, distance) in map.v.iter_mut().zip(distance) {
+        let t = (distance + 0.5 - (size - width)) / width;
+        let tail = if (0.0..=1.0).contains(&t) {
+            amp * t * t * (3.0 - 2.0 * t)
+        } else if (1.0..=2.0).contains(&t) {
+            let u = t - 1.0;
+            let remaining = 1.0 - u;
+            amp * remaining * remaining * (1.0 + 2.0 * u)
+        } else {
+            0.0
+        };
+        *coverage = coverage.max(tail);
+    }
+}
+
 /// Where a bevel's maps paint: inside the shape (with the layer), outside it (onto the
 /// backdrop), or both (emboss styles: maps 0–1 inside, 2–3 outside).
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -3222,6 +3266,56 @@ mod tests {
    

#7848: fix(retouch): preserve Smudge pickup with live-pixel stroke commits

crates/engine/src/retouch_cmds.rs

diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs
index fdbee1cb2..1a2530d3d 100644
--- a/crates/engine/src/retouch_cmds.rs
+++ b/crates/engine/src/retouch_cmds.rs
@@ -911,6 +911,37 @@ enum DabTool {
 /// One dab's edit of the working copy.
 pub(crate) type DabEffect = Box<dyn FnMut(&mut Region, &Footprint)>;
 
+/// Pixels an effect reads beyond the dab it writes. Smudge's pickup is a fixed-size square,
+/// independent of pressure, roundness and the dab's current angle.
+#[derive(Clone, Copy)]
+enum DabNeighborhood {
+    Halo(i32),
+    Smudge(i32),
+}
+
+impl DabNeighborhood {
+    fn halo(self) -> i32 {
+        match self {
+            Self::Halo(radius) | Self::Smudge(radius) => radius,
+        }
+    }
+
+    fn read_rect(self, rect: Rect, dab: &photocraft_paint::Dab) -> Rect {
+        match self {
+            Self::Halo(radius) => rect.inflate(radius),
+            Self::Smudge(radius) => {
+                let (x, y) = (dab.center.x.floor() as i32, dab.center.y.floor() as i32);
+                rect.union(&Rect::new(
+                    x.saturating_sub(radius),
+                    y.saturating_sub(radius),
+                    x.saturating_add(radius).saturating_add(1),
+                    y.saturating_add(radius).saturating_add(1),
+                ))
+            }
+        }
+    }
+}
+
 impl DabTool {
     /// The tool for `cmd` and its history label.
     fn parse(s: &Session, cmd: &str, p: &Value, stroke: &Stroke) -> Result<(Self, &'static str)> {
@@ -952,10 +983,11 @@ impl DabTool {
     }
 
     /// How far around each dab the effect reads (Blur and Sharpen read a neighbourhood).
-    fn halo(&self) -> i32 {
+    fn neighborhood(&self) -> DabNeighborhood {
         match self {
-            Self::Focus { sigma, .. } => (sigma * 3.0).ceil() as i32 + 1,
-            _ => 0,
+            Self::Focus { sigma, .. } => DabNeighborhood::Halo((sigma * 3.0).ceil() as i32 + 1),
+            Self::Smudge { max_size, .. } => DabNeighborhood::Smudge(Smudge::sampling_radius(*max_size)),
+            _ => DabNeighborhood::Halo(0),
         }
     }
 
@@ -1020,7 +1052,7 @@ fn dab_cmd(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
         let fmt = surf.format();
         let mut effect = tool.effect(fmt, stroke.brush.spacing);
         let mut sampler = all.then(|| AllLayers::new(pre, fmt));
-        Ok(apply_dab_stroke(surf, &stroke, sel, lock, tool.halo(), |work, fp| match &mut sampler {
+        Ok(apply_dab_stroke(surf, &stroke, sel, lock, tool.neighborhood().halo(), |work, fp| match &mut sampler {
             Some(all) => all.dab(work, fp, |w, f| effect(w, f)),
             None => effect(work, fp),
         }))
@@ -1120,7 +1152,7 @@ pub struct LiveDab {
     renderer: photocraft_paint::StrokeRenderer,
     done: usize,
     effect: DabEffect,
-    halo: i32,
+    neighborhood: DabNeighborhood,
     pre_surf: Surface,
     work: WorkTiles,
     id: Option<LayerId>,
@@ -1155,9 +1187,9 @@ impl LiveDab {
         }
         let (stroke, id) = parse_brush(s, p, cmd)?;
         let (tool, _) = DabTool::parse(s, cmd, p, &stroke)?;
-        let (halo, spacing, brush) = (tool.halo(), stroke.brush.spacing, Box::new(stroke.brush.clone()));
+        let (neighborhood, spacing, brush) = (tool.neighborhood(), stroke.brush.spacing, Box::new(stroke.brush.clone()));
         let effect = tool.clone();
-        let mut live = Self::with_effect(s, cmd, p, stroke, id, halo, |pre| effect.effect(pr

#7742: Actions: a fractional setting an integer field cannot hold is refused, not rounded

crates/engine/src/atn_cmds.rs, crates/engine/src/atn_cmds/tests.rs

diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs
index 9e8eb6cb7..decaed9a7 100644
--- a/crates/engine/src/atn_cmds.rs
+++ b/crates/engine/src/atn_cmds.rs
@@ -186,6 +186,15 @@ fn long(v: f64) -> D {
     D::Integer(v.round().clamp(f64::from(i32::MIN), f64::from(i32::MAX)) as i32)
 }
 
+/// `v` as a Photoshop integer field, or `None` when it is not one. A fractional setting written
+/// here would export an edit the user did not record -- `cellSize: 5.4` as `ClSz 5` plays back as
+/// a different image, with nothing in the file to say so (#7669, #7670). The step is refused
+/// instead: strict export names it, and `skipUnsupported` leaves it out.
+fn long_exact(v: f64) -> Option<D> {
+    let whole = v.fract() == 0.0 && (f64::from(i32::MIN)..=f64::from(i32::MAX)).contains(&v);
+    whole.then_some(D::Integer(v as i32))
+}
+
 fn text(s: &str) -> D {
     D::Text(UnicodeString::new_nul(s))
 }
@@ -389,6 +398,11 @@ enum Kind {
     Pct,
     Ang,
     Long,
+    /// An integer field whose command uses the setting as written, so a fraction cannot be
+    /// exported: see [`long_exact`]. Only for the fields that have been checked against their
+    /// command -- an event whose command rounds the setting itself can hold it after all
+    /// (`edit.stroke` ceils its width, so 2.4 really is 3; #7742 review).
+    LongExact,
     Dbl,
     Bool,
     /// An enumeration: its type id and the Photoshop ↔ PhotoCraft value pairs.
@@ -406,7 +420,7 @@ fn fields_from(d: &Descriptor, fields: &[F]) -> Option<Map<String, Value>> {
     let mut m = Map::new();
     for F(key, param, kind) in fields {
         let v = match kind {
-            Kind::Px | Kind::Pct | Kind::Ang | Kind::Long | Kind::Dbl => get_num(d, key).map(Value::from),
+            Kind::Px | Kind::Pct | Kind::Ang | Kind::Long | Kind::LongExact | Kind::Dbl => get_num(d, key).map(Value::from),
             Kind::Bool => get_bool(d, key).map(Value::from),
             Kind::Enum(_, table) => match get_enum(d, key) {
                 Some(e) => Some(Value::from(lookup(table, &e)?)),
@@ -436,6 +450,7 @@ fn fields_to(p: &Value, fields: &[F], mut d: Descriptor) -> Option<Descriptor> {
             Kind::Pct => pct(v.as_f64()?),
             Kind::Ang => ang(v.as_f64()?),
             Kind::Long => long(v.as_f64()?),
+            Kind::LongExact => long_exact(v.as_f64()?)?,
             Kind::Dbl => D::Double(v.as_f64()?),
             Kind::Bool => D::Boolean(v.as_bool()?),
             Kind::Enum(ty, table) => enm(ty, lookup_back(table, v.as_str()?)?),
@@ -2727,8 +2742,8 @@ const EVENTS: &[Event] = &[
         "filter.other.offset",
         &[F("Hrzn", "horizontal", Kind::Long), F("Vrtc", "vertical", Kind::Long), F("Fl  ", "undefinedAreas", Kind::Enum("FlMd", OFFSET_FILL))]
     ),
-    ev!("Rple", "ripple", "Ripple", "filter.distort.ripple", &[F("Amnt", "amount", Kind::Long), F("RplS", "size", Kind::Enum("RplS", RIPPLE_SIZE))]),
-    ev!("Crst", "crystallize", "Crystallize", "filter.pixelate.crystallize", &[F("ClSz", "cellSize", Kind::Long)]),
+    ev!("Rple", "ripple", "Ripple", "filter.distort.ripple", &[F("Amnt", "amount", Kind::LongExact), F("RplS", "size", Kind::Enum("RplS", RIPPLE_SIZE))]),
+    ev!("Crst", "crystallize", "Crystallize", "filter.pixelate.crystallize", &[F("ClSz", "cellSize", Kind::LongExact)]),
     Event { code: "GEfc", name: "filterGallery", label: "Filter Gallery", ids: GALLERY_IDS, from: gallery_from, to: gallery_to },
     // Adjustments (I

#7255: fix(engine): guard trim and duplicate merged against documents exceeding render limits (#7223)

crates/engine/src/image_cmds.rs

diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs
index 6d5342b93..d4308fb7e 100644
--- a/crates/engine/src/image_cmds.rs
+++ b/crates/engine/src/image_cmds.rs
@@ -720,11 +720,24 @@ fn turned_crop_source(old: Size, new: Size, angle: f64, target: Rect) -> Rect {
     Rect::new((x0 - KERNEL_REACH).floor() as i32, (y0 - KERNEL_REACH).floor() as i32, (x1 + KERNEL_REACH).ceil() as i32, (y1 + KERNEL_REACH).ceil() as i32)
 }
 
+/// Flatten the document only if its dimensions are within the compositor's render limit.
+fn flatten_within_render_limit(doc: &Document, op: &str) -> Result<photocraft_compose::Buffer> {
+    let bounds = doc.bounds();
+    if !bounds.is_empty() && u64::from(bounds.width()) * u64::from(bounds.height()) > photocraft_compose::MAX_RENDER_PIXELS {
+        return Err(EngineError::Other(format!("the document is too large to {op}")));
+    }
+    let buf = photocraft_compose::flatten(doc);
+    if buf.px.is_empty() && !bounds.is_empty() {
+        return Err(EngineError::Other(format!("the document is too large to {op}")));
+    }
+    Ok(buf)
+}
+
 /// Image → Trim.
 fn trim(s: &mut Session, p: &Value) -> Result<Value> {
     let d = s.active().ok_or(EngineError::NoDocument)?;
     let canvas = d.doc.bounds();
-    let buf = photocraft_compose::flatten(&d.doc);
+    let buf = flatten_within_render_limit(&d.doc, "trim")?;
     let based = p.get("basedOn").and_then(Value::as_str).unwrap_or("transparent");
     let w = canvas.width() as usize;
     let reference = match based {
@@ -803,7 +816,7 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
     doc.id = photocraft_doc::DocId::fresh();
     doc.name = p.get("name").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| format!("{} copy", d.doc.name));
     if p.get("mergedOnly").and_then(Value::as_bool).unwrap_or(false) {
-        let buf = photocraft_compose::flatten(&doc);
+        let buf = flatten_within_render_limit(&doc, "duplicate merged")?;
         let fmt = doc.pixel_format();
         let mut surf = Surface::new(fmt);
         let vals: Vec<f32> = buf.px.iter().flat_map(|q| photocraft_raster::from_rgba(&fmt, *q)).collect();
@@ -1687,4 +1700,31 @@ mod tests {
         assert_eq!(doc(&s).layers.len(), 1);
         assert!(doc(&s).name.ends_with("copy"));
     }
+
+    #[test]
+    fn trim_and_duplicate_merged_on_too_large_document_fail_gracefully_without_panic() {
+        let mut s = Session::new();
+        s.execute("file.new", json!({"width": 40000, "height": 30000, "background": "transparent"})).unwrap();
+        let initial_layers = doc(&s).layers.len();
+        let initial_bounds = doc(&s).bounds();
+        let initial_rev = s.active().unwrap().revision;
+        let initial_docs = s.documents().len();
+
+        let trim_res = s.execute("image.trim", json!({"basedOn": "topLeft"}));
+        assert!(trim_res.is_err(), "trim on too-large document must return error");
+        let err = trim_res.unwrap_err().to_string();
+        assert!(err.contains("too large to trim"), "error should explain document is too large: {err}");
+        assert_eq!(doc(&s).layers.len(), initial_layers, "layer count unchanged after failed trim");
+        assert_eq!(doc(&s).bounds(), initial_bounds, "canvas size unchanged after failed trim");
+        assert_eq!(s.active().unwrap().revision, initial_rev, "revision unchanged after failed trim");
+
+        let dup_res = s.execute("image.duplicate", json!({"mergedOnly": true}));
+  

#6621: Crop: right-click on the pending crop opens the crop menu (#6561)

crates/ui-egui/src/canvas.rs, crates/ui-egui/src/canvas_tool_menu.rs, crates/ui-egui/src/control.rs, crates/ui-egui/src/crop_ui.rs, crates/ui-egui/src/transform_type_tests.rs, docs/context-menu-parity.md, docs/scorecard.md

diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index ab3e6939a..948e24e8f 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -4272,6 +4272,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
             // menu belongs to the selected Zoom or Hand, not to Space or a held zoom key.
             if crate::canvas_tool_menu::pasteboard_at(app, tool, xf.to_doc(p)) {
                 crate::canvas_tool_menu::open_pasteboard(app, &ctx, tool, [p.x, p.y]);
+            } else if tool == Tool::Crop && crate::crop_ui::pending(app) {
+                // On a pending crop frame: the crop menu (#6561).
+                crate::canvas_tool_menu::open_crop(app, &ctx, [p.x, p.y]);
             } else if crate::canvas_tool_menu::is_view_tool(tool) {
                 if tool == app.ui.tool {
                     crate::canvas_tool_menu::open_view(app, &ctx, tool, [p.x, p.y], xf.to_doc(p));
@@ -6840,12 +6843,14 @@ pub fn commit_crop(app: &mut PhotocraftApp) {
     // The crop's default mode leaves the view upright (`crop_mode`).
     crate::crop_mode::upright(app);
     // The untouched default frame around the whole canvas crops nothing (Photoshop's ↵ on it does
-    // nothing); one framing the selection's bounds crops to them (#1789).
-    if std::mem::take(&mut app.crop.default_frame) {
-        let whole = app.session.active().map(|st| st.doc.bounds()).map(|b| [f64::from(b.x0), f64::from(b.y0), f64::from(b.x1), f64::from(b.y1)]);
-        if whole.is_none_or(|b| b == r) {
-            return;
-        }
+    // nothing); one framing the selection's bounds crops to them (#1789). The crop menu's Reset
+    // (#6561) leaves a full-canvas frame that stays pending: committing it crops nothing either,
+    // unless it was turned since or W x H x Resolution resamples it.
+    let default_frame = std::mem::take(&mut app.crop.default_frame);
+    let reset_frame = std::mem::take(&mut app.crop.reset_frame);
+    let whole = app.session.active().map(|st| st.doc.bounds()).map(|b| [f64::from(b.x0), f64::from(b.y0), f64::from(b.x1), f64::from(b.y1)]);
+    if whole.is_none_or(|b| b == r) && (default_frame || (reset_frame && crate::crop_ui::angle(app) == 0.0 && !crate::crop_size::resamples(app))) {
+        return;
     }
     let (x, y) = (r[0].round(), r[1].round());
     let (w, h) = ((r[2] - r[0]).round().max(1.0), (r[3] - r[1]).round().max(1.0));
diff --git a/crates/ui-egui/src/canvas_tool_menu.rs b/crates/ui-egui/src/canvas_tool_menu.rs
index e4256949e..6cadb706a 100644
--- a/crates/ui-egui/src/canvas_tool_menu.rs
+++ b/crates/ui-egui/src/canvas_tool_menu.rs
@@ -42,6 +42,13 @@ pub struct CanvasToolMenu {
     /// the menu.
     #[serde(default)]
     pub spelling: Option<Spelling>,
+    /// Opened on a pending crop frame: the crop menu (#6561).
+    #[serde(default)]
+    pub crop: bool,
+    /// The saved crop preset names, snapshotted when the crop menu opened: `crop.preset.<n>`
+    /// picks the n-th, so the rows cannot shift under an open menu.
+    #[serde(default)]
+    pub crop_presets: Vec<String>,
 }
 
 /// A misspelled word in the type layer being edited (`edit.checkSpelling`'s `list` entry).
@@ -196,6 +203,63 @@ pub const GUIDE_MENU: &[Row] = &[
     Some(("Clear Selected Artboard Guides", "view.clearSelectedArtboardGuides")),
 ];
 
+/// The crop menu's ratio rows: `crop.ratio.<key>` sets that options-bar ratio (`crop_ratio`) and
+/// fits the frame, exactly 

#6492: Fail control startup promptly when its port is occupied

apps/photocraft/src/control_server.rs, apps/photocraft/src/main.rs, apps/photocraft/tests/it/control_exit_code.rs

diff --git a/apps/photocraft/src/control_server.rs b/apps/photocraft/src/control_server.rs
index 30ae610b7..f8462e73f 100644
--- a/apps/photocraft/src/control_server.rs
+++ b/apps/photocraft/src/control_server.rs
@@ -16,16 +16,12 @@ use photocraft_ui_egui::ControlRequest;
 use photocraft_ui_egui::control::REPLY_DEADLINE;
 use serde_json::{Value, json};
 
-pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRequest> {
+/// Start serving a listener already bound during startup, before a window is opened.
+pub fn start(listener: TcpListener, token: String, ctx: egui::Context) -> Receiver<ControlRequest> {
     let (tx, rx) = channel::<ControlRequest>();
-    let listener = match TcpListener::bind(("127.0.0.1", port)) {
-        Ok(l) => l,
-        Err(e) => {
-            eprintln!("photocraft: control server failed to bind 127.0.0.1:{port}: {e}");
-            return rx;
-        }
-    };
-    eprintln!("photocraft: control server listening on 127.0.0.1:{port}");
+    if let Ok(address) = listener.local_addr() {
+        eprintln!("photocraft: control server listening on {address}");
+    }
     std::thread::spawn(move || {
         let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
         let token = Arc::new(token);
@@ -131,6 +127,30 @@ mod tests {
     use super::*;
     use std::io::BufRead;
 
+    #[test]
+    fn a_prebound_listener_authenticates_and_dispatches_requests() {
+        const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+        let address = listener.local_addr().unwrap();
+        let requests = start(listener, TOKEN.into(), egui::Context::default());
+        let mut stream = TcpStream::connect(address).unwrap();
+        stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
+        let mut replies = BufReader::new(stream.try_clone().unwrap());
+        writeln!(stream, "{}", json!({"id": 1, "method": "auth", "params": {"token": TOKEN}})).unwrap();
+        let mut line = String::new();
+        replies.read_line(&mut line).unwrap();
+        assert_eq!(serde_json::from_str::<Value>(&line).unwrap()["result"]["authenticated"], true);
+        writeln!(stream, "{}", json!({"id": 2, "method": "test.bound"})).unwrap();
+        let request = requests.recv_timeout(Duration::from_secs(5)).unwrap();
+        assert_eq!(request.method, "test.bound");
+        request.reply.send(json!({"ok": true, "result": "served"})).unwrap();
+        line.clear();
+        replies.read_line(&mut line).unwrap();
+        let reply: Value = serde_json::from_str(&line).unwrap();
+        assert_eq!(reply["id"], 2);
+        assert_eq!(reply["result"], "served");
+    }
+
     /// #4965: a connection that keeps sending without authenticating is closed at the deadline
     /// (releasing its connection permit), and nothing it sent is dispatched.
     #[test]
diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs
index c0f93bd31..d8ead0674 100644
--- a/apps/photocraft/src/main.rs
+++ b/apps/photocraft/src/main.rs
@@ -284,6 +284,43 @@ fn main() -> eframe::Result {
         std::process::exit(code);
     }
 
+    // Complete control setup before display/GPU initialization: a failed grant must exit 2,
+    // including an occupied port (#6428), rather than open a window without a control server.
+    let control = if let Some(port) = control_port {
+        let listener = match std::net

#6449: Hand tool: Cmd held zooms in and Alt zooms out, as in Photoshop

crates/ui-egui/src/input/hold_keys.rs, crates/ui-egui/src/input/hold_keys/tests.rs, docs/ui-parity.md

diff --git a/crates/ui-egui/src/hold_keys.rs b/crates/ui-egui/src/hold_keys.rs
index 3f2845cae..1da497021 100644
--- a/crates/ui-egui/src/hold_keys.rs
+++ b/crates/ui-egui/src/hold_keys.rs
@@ -16,6 +16,8 @@
 //!   selection tools do their own ⌘ handling in `canvas` (⌘ inside the selection cuts the
 //!   selected pixels, outside it moves the layer, #896), and the Hand, Zoom, Crop, Slice, Path
 //!   Selection, shape, Pen and Type tools keep ⌘ for themselves.
+//! - With the Hand tool, ⌘ (Ctrl off the Mac) held is Zoom In and ⌥ (Alt) Zoom Out, ⌘⌥ too
+//!   ([`hand_zoom_held`], #6434).
 //! - `~` (the backtick key) with the Brush or Pencil: a stroke started while it is held erases,
 //!   with that tool's own brush ([`erase_held`], #3439).
 //!
@@ -118,6 +120,23 @@ fn cmd_move_held(app: &PhotocraftApp, i: &InputState) -> bool {
     i.modifiers.command && cmd_moves(app.ui.tool) && app.ui.transform.is_none() && app.ui.text_edit.is_none()
 }
 
+/// The Hand tool's own temporary zoom (#6434): ⌘ held is Zoom In, ⌥ (with or without ⌘) Zoom Out,
+/// as in Photoshop. Not while a Free Transform keeps ⌘ for its handles, and not from the middle of
+/// a Hand drag: a modifier pressed while panning must not stop the pan.
+fn hand_zoom_held(app: &PhotocraftApp, i: &InputState) -> Option<Temporary> {
+    let panning = i.pointer.primary_down() && !i.pointer.primary_pressed();
+    if app.ui.tool != Tool::Hand || app.ui.transform.is_some() || panning {
+        return None;
+    }
+    if i.modifiers.alt {
+        Some(Temporary::ZoomOut)
+    } else if i.modifiers.command {
+        Some(Temporary::ZoomIn)
+    } else {
+        None
+    }
+}
+
 /// Is `id` a held temporary tool rather than a command?
 pub fn is_temporary(id: &str) -> bool {
     photocraft_engine::prefs::TEMPORARY_TOOLS.iter().any(|t| t.0 == id)
@@ -159,6 +178,7 @@ pub fn held_tool(app: &PhotocraftApp, ctx: &egui::Context) -> Option<Temporary>
     };
     let mut best: Option<(u8, Temporary, KeyboardShortcut)> = None;
     let mut cmd_move = false;
+    let mut hand_zoom = None;
     let mut alt = false;
     ctx.input(|i| {
         for t in Temporary::BOUND {
@@ -170,13 +190,14 @@ pub fn held_tool(app: &PhotocraftApp, ctx: &egui::Context) -> Option<Temporary>
             }
         }
         cmd_move = cmd_move_held(app, i);
+        hand_zoom = hand_zoom_held(app, i);
         alt = i.modifiers.alt;
     });
     // ⌥ turns a held Zoom In around, as it does the Zoom tool: ⌘⌥Space zooms out (and wins the
     // tie with ⌥Space), unless ⌥ is part of the Zoom In key itself.
     let held = best.map(|(_, t, sc)| if t == Temporary::ZoomIn && alt && !sc.modifiers.alt { Temporary::ZoomOut } else { t });
-    // A bound key (⌘Space is Zoom In) wins over ⌘ alone.
-    held.or(cmd_move.then_some(Temporary::Move))
+    // A bound key (⌘Space is Zoom In) wins over ⌘ or ⌥ alone.
+    held.or(cmd_move.then_some(Temporary::Move)).or(hand_zoom)
 }
 
 /// The binding id of the Brush and Pencil erase key (`prefs::TEMPORARY_TOOLS`).
diff --git a/crates/ui-egui/src/hold_keys/tests.rs b/crates/ui-egui/src/hold_keys/tests.rs
index cfe89d464..9e3dbb604 100644
--- a/crates/ui-egui/src/hold_keys/tests.rs
+++ b/crates/ui-egui/src/hold_keys/tests.rs
@@ -643,13 +643,67 @@ fn cmd_is_not_the_move_tool_for_selection_hand_zoom_pen_shape_or_type_tools() {
         let mut h = harness(tool);
         h.event(Event::ModifiersChanged(platform(Modifiers::COMMAND)));
         h.run_steps(1);
-        assert_eq

#6236: Shift+Alt+right-click opens Photoshop's HUD Color Picker (#6098)

crates/ui-egui/src/brush_resize.rs, crates/ui-egui/src/canvas.rs, crates/ui-egui/src/lib.rs, crates/ui-egui/src/paint_mouse.rs

diff --git a/crates/ui-egui/src/brush_resize.rs b/crates/ui-egui/src/brush_resize.rs
index 7d41aaa1b..a454932d3 100644
--- a/crates/ui-egui/src/brush_resize.rs
+++ b/crates/ui-egui/src/brush_resize.rs
@@ -49,9 +49,10 @@ pub fn is_gesture(mods: egui::Modifiers) -> bool {
 }
 
 /// Alt (Option) with the right button: Photoshop's Windows gesture (#297). The left button with
-/// Alt alone stays the painting tools' eyedropper.
+/// Alt alone stays the painting tools' eyedropper. Shift+Alt+right is Photoshop's HUD Color
+/// Picker (#6098), so Shift disqualifies the resize gesture.
 pub fn is_right_gesture(mods: egui::Modifiers) -> bool {
-    mods.alt
+    mods.alt && !mods.shift
 }
 
 /// The diameter and hardness a drag from `start` by (`dx`, `dy`) document pixels gives at `zoom`.
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index ab3e6939a..a48307db7 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -2587,6 +2587,8 @@ pub fn document_area(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
     crate::layer_transfer::finish(app, ui.ctx());
     // Once a frame, not per document view: the right-click or options-bar Brush Preset picker.
     crate::paint_mouse::show_picker(app, ui.ctx());
+    // And the Shift+Alt+right-click HUD Color Picker (#6098), while its gesture is held.
+    crate::color_hud::show(app, ui.ctx());
 }
 
 /// Window › Arrange tiles: each document shown with its tile in `rect`, or `None` when the
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 4db3dc2a3..ae902e5fa 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -60,6 +60,7 @@ pub mod channels_panel;
 pub mod chrome_ui;
 pub mod cjk_fonts;
 pub mod clip_line_ui;
+pub mod color_hud;
 pub mod color_panel_ui;
 pub mod color_picker_ui;
 pub mod color_range_ui;
diff --git a/crates/ui-egui/src/paint_mouse.rs b/crates/ui-egui/src/paint_mouse.rs
index 84695522d..75a8d6392 100644
--- a/crates/ui-egui/src/paint_mouse.rs
+++ b/crates/ui-egui/src/paint_mouse.rs
@@ -136,12 +136,24 @@ pub fn canvas_buttons(app: &mut PhotocraftApp, response: &Response, tool: Tool)
     // ⌘/Ctrl+right-click lists the layers under the pointer instead (layer_pick_ui.rs, #307).
     let layer_menu = crate::layer_pick_ui::is_gesture(tool, mods);
     let erase = right_erases(app, tool) && !resize_start && !resizing && !layer_menu;
+    // Shift+Alt+right-press opens Photoshop's HUD Color Picker (#6098) instead of the picker.
+    // Tools whose right-click is the canvas tool menu (Quick Selection, #7268) keep that menu.
+    let sticky = crate::workspace_ui::sticky_mods(app, mods);
+    let hud_gesture = right_click_picker(tool) && !layer_menu && !erase && sticky.shift && sticky.alt;
+    let right_press = response.ctx.input(|i| i.pointer.button_pressed(PointerButton::Secondary)) && response.contains_pointer();
+    if hud_gesture
+        && right_press
+        && let Some(p) = response.ctx.input(|i| i.pointer.hover_pos())
+    {
+        crate::color_hud::open(app, [p.x, p.y], true);
+    }
     let right_stroke = erase && app.drag.is_some();
     let right_start = erase && right_started;
     let right_click = response.secondary_clicked();
     if right_click
         && !erase
         && !layer_menu
+        && !crate::color_hud::is_open(&app.ui)
         && right_click_picker(tool)
         && let Some(p) = response.interact_pointer_pos()
     {
@@ -164,6 +176,22 @@ pub fn canvas_buttons(app: &mut

#6139: Fix painting workflows, File Info and layer selection

crates/engine/src/cmd/brush_cmds.rs, crates/paint/src/render.rs, crates/ui-egui/src/canvas.rs, crates/ui-egui/src/dialogs.rs, crates/ui-egui/src/i18n/cs.tsv, crates/ui-egui/src/i18n/de.tsv, crates/ui-egui/src/i18n/hu.tsv, crates/ui-egui/src/i18n/it.tsv, crates/ui-egui/src/i18n/ja.tsv, crates/ui-egui/src/i18n/pt-br.tsv, crates/ui-egui/src/i18n/tr.tsv, crates/ui-egui/src/i18n/uk.tsv, crates/ui-egui/src/i18n/zh-hans.tsv, crates/ui-egui/src/i18n/zh-hant.tsv, crates/ui-egui/src/input_tests.rs, crates/ui-egui/src/menus.rs, crates/ui-egui/src/move_mods.rs, crates/ui-egui/src/panels.rs, crates/ui-egui/src/vector_ui.rs, crates/ui-egui/src/view_cmds.rs

diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs
index 5f59aafad..c1db2498c 100644
--- a/crates/paint/src/render.rs
+++ b/crates/paint/src/render.rs
@@ -1038,7 +1038,21 @@ impl StrokeRenderer {
                             src[ch] = p[ch] / c;
                         }
                     }
-                    if matches!(b.mode, BlendMode::Normal | BlendMode::Dissolve) {
+                    if b.behind {
+                        let Some(a) = a_idx else { continue };
+                        let da = px[a];
+                        if lock_transparency || da >= 1.0 {
+                            continue;
+                        }
+                        let underneath = src[a] * k * (1.0 - da);
+                        let alpha = da + underneath;
+                        if alpha > 0.0 {
+                            for channel in 0..a {
+                                px[channel] = (px[channel] * da + src[channel] * underneath) / alpha;
+                            }
+                            px[a] = alpha;
+                        }
+                    } else if matches!(b.mode, BlendMode::Normal | BlendMode::Dissolve) {
                         over_native(&fmt, px, &src[..n], k, lock_transparency);
                     } else {
                         let d = to_rgba(&fmt, px);
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 391ad464f..6fbb502fe 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -3272,6 +3272,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
     crate::crop_ui::cancel_stale(app);
     crate::crop_ui::ensure_frame(app);
     let mut drawing = crate::crop_ui::active(app);
+    if let Some(pen) = app.ui.pen.as_mut().filter(|pen| pen.dragging && !pen.curvature) {
+        pen.reposition = reposition;
+        drawing = true;
+    }
     if let Some(d) = app.drag.as_mut().filter(|d| crate::hold_keys::repositions(d.tool)) {
         d.reposition = reposition;
         drawing = true;
@@ -4009,6 +4013,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
                         pen_cursor(ui.ctx(), tool, p)
                     }
                 }
+                // The native copy pointer signals Alt-drag duplication without app-specific artwork.
+                Tool::Move if crate::move_mods::copying(app, alt) => egui::CursorIcon::Copy,
                 // Preferences › Cursors › Other Cursors: Precise shows a crosshair for every tool.
                 Tool::Move | Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask
                     if app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise =>
@@ -4295,7 +4301,7 @@ pub(crate) fn marching_ants_lines(painter: &egui::Painter, lines: impl Iterator<
     };
     for (pa, pb) in lines {
         // A transformed outline can land anywhere, or nowhere (a point on a Distort's horizon).
-        if !(pa.is_finite() && pb.is_finite()) || !clip.intersects(Rect::from_two_pos(pa, pb).expand(1.0)) {
+        if !(pa.is_finite() && pb.is_finite() && clip.intersects(Rect::from_two_pos(pa, pb).expand(1.0))) {
             continue;
         }
         let (pa, pb) = if pa.distance(pb) > LONG {
@@ -4993,9 +4999,12 @@ fn alt_eyedropper(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
 }
 
 fn sample_eyedropper(app: &mut PhotocraftApp, x: f64, y: f6

#6026: Marquee: Fixed Size selects exactly the entered width and height (fixes #6015)

crates/ui-egui/src/canvas.rs, crates/ui-egui/src/marquee_tests.rs

diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 391ad464f..96c979311 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -237,14 +237,16 @@ pub fn marquee_corners(o: &crate::state::ToolOptions, d: &Drag, end: [f64; 2]) -
 /// and readout) exactly as the release commits it, on whole pixel edges as in Photoshop. `None`
 /// while the gesture is still a click (it never left the press point by more than a few screen
 /// pixels at `zoom`, points per document pixel) or the rectangle has no area: a release then
-/// deselects. A one-pixel row or column is a selection (#2391).
+/// deselects. A one-pixel row or column is a selection (#2391). With the Fixed Size style a
+/// click places the selection, as in Photoshop.
 pub(crate) fn marquee_preview_px(o: &crate::state::ToolOptions, d: &Drag, zoom: f32) -> Option<[f64; 4]> {
-    if !matches!(d.tool, Tool::RectMarquee | Tool::EllipseMarquee) || crate::shape_dialog::is_click(d.start, &d.points, zoom) {
+    let fixed = o.marquee_style == "fixedSize";
+    if !matches!(d.tool, Tool::RectMarquee | Tool::EllipseMarquee) || (!fixed && crate::shape_dialog::is_click(d.start, &d.points, zoom)) {
         return None;
     }
     let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]);
     let (a, b) = marquee_corners(o, d, last);
-    let mut r = marquee_px(a, b);
+    let mut r = if fixed { fixed_size_px(a, b, o.marquee_width, o.marquee_height) } else { marquee_px(a, b) };
     if marquee_is_square(o, d) {
         r = square_px(r, d.start, last);
     }
@@ -256,6 +258,16 @@ pub fn marquee_px(a: [f64; 2], b: [f64; 2]) -> [f64; 4] {
     [a[0].min(b[0]).floor(), a[1].min(b[1]).floor(), a[0].max(b[0]).ceil(), a[1].max(b[1]).ceil()]
 }
 
+/// The pixel rectangle of a Fixed Size marquee between the corners `a` and `b`: exactly `w` × `h`
+/// whole pixels. Snapping both edges outwards (`marquee_px`) added a pixel to each side whenever
+/// the press point sat between pixels, so 10 × 10 selected 11 × 11 (#6015). The near edge goes
+/// to the closest pixel edge and the far one is the size away.
+pub fn fixed_size_px(a: [f64; 2], b: [f64; 2], w: f32, h: f32) -> [f64; 4] {
+    let (w, h) = (f64::from(w).round().max(1.0), f64::from(h).round().max(1.0));
+    let (x0, y0) = (a[0].min(b[0]).round(), a[1].min(b[1]).round());
+    [x0, y0, x0 + w, y0 + h]
+}
+
 /// Whether the marquee drag `d` is constrained to a square (or circle): ⇧ during the drag with
 /// the Normal style, or a Fixed Ratio of 1:1. Mirrors the branches of `chrome_ui::marquee_end`.
 fn marquee_is_square(o: &crate::state::ToolOptions, d: &Drag) -> bool {
@@ -6043,6 +6055,19 @@ mod tests {
         assert_eq!(square_px([10.0, 20.0, 30.0, 40.0], [10.0, 20.0], [30.0, 40.0]), [10.0, 20.0, 30.0, 40.0]);
     }
 
+    /// #6015: a Fixed Size marquee pressed between pixels selected one pixel more on each side.
+    #[test]
+    fn fixed_size_px_is_exactly_the_size() {
+        use super::fixed_size_px;
+        // Down-right from (10.4, 20.6): 10 × 10 from the closest pixel edges.
+        assert_eq!(fixed_size_px([10.4, 20.6], [20.4, 30.6], 10.0, 10.0), [10.0, 21.0, 20.0, 31.0]);
+        // Up-left: the far corner is the low one; still exactly the size.
+        assert_eq!(fixed_size_px([10.4, 20.6], [-53.6, -11.4], 64.0, 32.0), [-54.0, -11.0, 10.0, 21.0]);
+        // 1 × 1 is one pixel, and a fractional size rounds to whole pixels.
+        assert_eq!(fixed_size_px([3.5, 3.5], [4.5, 

#5744: fix(ui): keep detached documents in one interactive window

crates/ui-egui/src/canvas.rs, crates/ui-egui/src/lib.rs, crates/ui-egui/src/menus.rs, crates/ui-egui/src/view_cmds.rs

diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 7a9592b69..413e29872 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -1968,11 +1968,28 @@ pub fn document_area(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
 
 /// Window › Arrange tiles: each document shown with its tile in `rect`, or `None` when the
 /// active document fills the area.
+fn docked_document_indices(app: &PhotocraftApp) -> Vec<usize> {
+    (0..app.session.documents().len()).filter(|&doc| !crate::view_cmds::document_is_floating(app, doc)).collect()
+}
+
+fn main_canvas_document(app: &PhotocraftApp) -> Option<usize> {
+    let active = app.session.active_index()?;
+    if !crate::view_cmds::document_is_floating(app, active) {
+        return Some(active);
+    }
+    docked_document_indices(app).into_iter().next()
+}
+
 pub(crate) fn arranged_cells(app: &PhotocraftApp, rect: Rect) -> Option<Vec<(usize, Rect)>> {
-    let idx = app.session.active_index()?;
-    let n = app.session.documents().len();
+    let documents = docked_document_indices(app);
+    if documents.is_empty() {
+        return None;
+    }
+    let active = app.session.active_index();
+    let start = active.and_then(|doc| documents.iter().position(|&d| d == doc)).unwrap_or(0);
+    let n = documents.len();
     let cells = crate::view_cmds::cells(&app.ui.view.arrange, rect, n)?;
-    let mut shown: Vec<(usize, Rect)> = (0..n).map(|k| (idx + k) % n).zip(cells).collect();
+    let mut shown: Vec<(usize, Rect)> = (0..n).map(|k| documents[(start + k) % n]).zip(cells).collect();
     shown.sort_by_key(|(d, _)| *d);
     Some(shown)
 }
@@ -1996,6 +2013,12 @@ fn documents(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
         paint_dots(ui, ui.available_rect_before_wrap(), app.session.prefs().interface.show_canvas_dots);
         return;
     }
+    if n > 0 && !opening && docked_document_indices(app).is_empty() {
+        // A torn-off document belongs to its OS window. Leaving its canvas in the main
+        // viewport would make the same page appear twice, which is what “New Window” does.
+        paint_dots(ui, ui.available_rect_before_wrap(), app.session.prefs().interface.show_canvas_dots);
+        return;
+    }
     if !app.ui.view.hides_tabs() || opening {
         // Flat Spectrum tabs sit directly against the canvas. The parent's default vertical
         // item spacing would otherwise leave a visible gap below the tab strip (#2188).
@@ -2011,7 +2034,12 @@ fn documents(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
         crate::jobs_ui::open_card(app, ui, job);
         return;
     }
-    let Some(idx) = app.session.active_index() else { return };
+    let Some(active_doc) = app.session.active_index() else { return };
+    let Some(idx) = main_canvas_document(app) else {
+        paint_dots(ui, ui.available_rect_before_wrap(), app.session.prefs().interface.show_canvas_dots);
+        return;
+    };
+    let main_focused = ui.ctx().input(|i| i.viewport().focused.unwrap_or(true));
     let rect = ui.available_rect_before_wrap();
     app.last_canvas_rect = rect;
     app.drop_canvas_rect = Some(rect);
@@ -2022,20 +2050,23 @@ fn documents(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
         for (d, cell) in shown {
             let cell = cell.shrink(1.0);
             let view = app.ui.views[d].clone();
-            let v = canvas_view(app, ui, d, cell, view, d == idx);
-            if d != idx {
+            let v = canvas_view(app, 

#5650: Guides: ⌥ turns a dragged guide; dropping one on either ruler deletes it (#5034)

crates/engine/src/edit_cmds.rs, crates/ui-egui/src/canvas.rs, crates/ui-egui/src/rulers.rs, crates/ui-egui/src/tools/move_hover.rs, docs/ui-parity.md

diff --git a/crates/engine/src/edit_cmds.rs b/crates/engine/src/edit_cmds.rs
index 20ef23a41..fec1c4fc8 100644
--- a/crates/engine/src/edit_cmds.rs
+++ b/crates/engine/src/edit_cmds.rs
@@ -838,7 +838,18 @@ fn guide_color(p: &Value) -> Result<Option<[u8; 3]>> {
 /// View › New Guide / guide moves (undoable, like Photoshop's "New Guide"/"Move Guide" states).
 fn guide_cmd(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
     let vertical = p.get("orientation").and_then(Value::as_str).unwrap_or("horizontal") == "vertical";
-    let pos = guide_position(s, p, vertical)?;
+    // Move Guide can turn the guide (⌥ while dragging it, as in Photoshop): `toOrientation`.
+    let to_vertical = match p.get("toOrientation").map(|v| v.as_str()) {
+        None => vertical,
+        Some(Some("vertical")) => true,
+        Some(Some("horizontal")) => false,
+        Some(_) => return Err(EngineError::Other("`toOrientation` must be \"horizontal\" or \"vertical\"".into())),
+    };
+    if to_vertical != vertical && op != "move" {
+        return Err(EngineError::Other("`toOrientation` applies to view.moveGuide only".into()));
+    }
+    // A % position is of the axis the guide ends up on.
+    let pos = guide_position(s, p, to_vertical)?;
     let color = if op == "new" { guide_color(p)? } else { None };
     let index = p.get("index").and_then(Value::as_u64).map(|v| v as usize);
     let label = match op {
@@ -861,10 +872,20 @@ fn guide_cmd(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
             "move" => {
                 let i = index.filter(|i| *i < g.len()).ok_or(EngineError::Other("no such guide".into()))?;
                 let to = pos.ok_or(EngineError::Other("missing `position`".into()))?;
-                let from = std::mem::replace(&mut g[i], to);
+                let from = if to_vertical == vertical {
+                    std::mem::replace(&mut g[i], to)
+                } else {
+                    let from = g.remove(i);
+                    if to_vertical {
+                        guides.vertical.push(to)
+                    } else {
+                        guides.horizontal.push(to)
+                    }
+                    from
+                };
                 // The guide takes its colour along; another guide left at the old position keeps it.
                 if let Some(rgb) = guides.color(vertical, from) {
-                    guides.set_color(vertical, to, Some(rgb));
+                    guides.set_color(to_vertical, to, Some(rgb));
                 }
                 guides.prune_colors();
             }
@@ -1010,7 +1031,7 @@ pub fn specs() -> Vec<CommandSpec> {
             "Move Guide",
             &[],
             None,
-            r##"{"orientation":"horizontal|vertical","index":n,"position":px or "<number><unit>"} (units as for view.newGuide)"##,
+            r##"{"orientation":"horizontal|vertical","index":n,"position":px or "<number><unit>","toOrientation":"horizontal|vertical"?} (units as for view.newGuide; toOrientation turns the guide, its position then on the new axis)"##,
             has_doc,
             |s, p| guide_cmd(s, p, "move")
         ),
@@ -1969,6 +1990,35 @@ mod tests {
         assert!(s.execute("view.moveGuide", json!({"orientation": "vertical", "index": 3, "position": 1})).is_err());
     }
 
+    /// #5034: Move Guide turns a guide with `toOrientation` (⌥ while dragging it), in one undo
+    /// step, taking its colour along; a % position is of the new axis.
+  

#5515: Selecting subject now automatically applies to selected layer, and us…

crates/engine/src/cmd/smartselect_cmds.rs

diff --git a/crates/engine/src/smartselect_cmds.rs b/crates/engine/src/smartselect_cmds.rs
index 380b6a720..6a2ee6199 100644
--- a/crates/engine/src/smartselect_cmds.rs
+++ b/crates/engine/src/smartselect_cmds.rs
@@ -294,7 +294,8 @@ impl SubjectCache {
 }
 
 fn select_subject(s: &mut Session, p: &Value) -> Result<Value> {
-    let all_layers = b(p, "sampleAllLayers", true);
+    // The selected layer's pixels; the composite only when no pixel layer is selected.
+    let all_layers = b(p, "sampleAllLayers", false);
     let d = s.active().ok_or(EngineError::NoDocument)?;
     // What `with_doc_sampler` samples: the active layer's pixels, else the composite.
     let target = d.active_layer.filter(|id| !all_layers && d.doc.layer(*id).and_then(Layer::surface).is_some());
@@ -516,7 +517,7 @@ pub fn specs() -> Vec<CommandSpec> {
             "select.subject",
             "Subject",
             ["Select"],
-            r##"{"sampleAllLayers":bool=true,"mode":"replace|add|subtract|intersect"="replace"}"##,
+            r##"{"sampleAllLayers":bool=false,"mode":"replace|add|subtract|intersect"="replace"} (samples the selected layer; the composite when no pixel layer is selected)"##,
             has_doc,
             select_subject
         ),
@@ -778,6 +779,23 @@ mod tests {
         assert!(s.active().unwrap().doc.selection.is_none());
     }
 
+    /// Select Subject looks at the selected layer only; with no layer selected it looks at the
+    /// whole image.
+    #[test]
+    fn select_subject_samples_the_selected_layer_else_the_composite() {
+        let (mut s, inside) = disc(8);
+        // A layer above holding only a small square in the corner: the disc is not on it.
+        s.execute("layer.new.layer", json!({})).unwrap();
+        s.execute("paint.pencil", json!({"points": [[10, 10], [30, 10]], "size": 3, "color": "#000000"})).unwrap();
+        s.execute("select.subject", json!({})).unwrap();
+        assert!(cov(&s, 100, 80) < 0.5, "the disc on the layer below was selected");
+        s.execute("select.deselect", json!({})).unwrap();
+        // No layer selected: the whole image, so the disc is found.
+        s.execute("select.deselectLayers", json!({})).unwrap();
+        s.execute("select.subject", json!({})).unwrap();
+        assert!(sel_iou(&s, 200, 160, &inside) > 0.9);
+    }
+
     /// #2865: a repeat on unchanged pixels reuses the last mask (an identical selection, even
     /// after Deselect); sampling something else or a pixel edit computes it again.
     #[test]
@@ -795,8 +813,8 @@ mod tests {
         s.execute("select.subject", json!({})).unwrap();
         assert!(std::sync::Arc::ptr_eq(&mask, &cached(&s)), "the repeat recomputed the mask");
         assert_eq!(sel(&s), first);
-        // Another sampled target (the layer rather than the composite) is a miss.
-        s.execute("select.subject", json!({"sampleAllLayers": false})).unwrap();
+        // Another sampled target (the composite rather than the layer) is a miss.
+        s.execute("select.subject", json!({"sampleAllLayers": true})).unwrap();
         assert!(!std::sync::Arc::ptr_eq(&mask, &cached(&s)));
         // A pixel edit drops the entry; the next run recomputes it.
         s.edit("paint", |doc, _| {

#5493: Import Photoshop custom shape sets into the Shapes panel

docs/file-format-parity.md, docs/gaps.md

#5453: Shapes: independent fill and stroke paints (solid, gradient, pattern), part 1

crates/compose/src/lib.rs, crates/compose/src/shape_split.rs, crates/engine/src/vector_cmds.rs, crates/gpu/src/plan.rs, crates/vector/src/lib.rs, docs/control-protocol.md

diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs
index 8813b122d..323f243e9 100644
--- a/crates/compose/src/lib.rs
+++ b/crates/compose/src/lib.rs
@@ -1445,7 +1445,7 @@ fn shape_parts(layer: &Layer, clipped: &[Layer], rect: Rect, cx: &Ctx) -> Option
 fn split_parts(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<(Buffer, Buffer, Option<Vec<f32>>)> {
     let LayerContent::Shape(sh) = &layer.content else { return None };
     sh.stroke.as_ref()?;
-    let (fs, ss) = shape_split::split(sh, cx.canvas, cx.depth)?;
+    let (fs, ss) = shape_split::split_with_patterns(sh, cx.canvas, cx.depth, cx.patterns.source(), cx.mode)?;
     Some((surface_to_buffer(&fs, rect), surface_to_buffer(&ss, rect), mask_vals(layer, rect, cx)))
 }
 
diff --git a/crates/compose/src/shape_split.rs b/crates/compose/src/shape_split.rs
index 9f0143f88..d541de7ec 100644
--- a/crates/compose/src/shape_split.rs
+++ b/crates/compose/src/shape_split.rs
@@ -6,14 +6,15 @@ use std::collections::HashMap;
 use std::hash::{Hash, Hasher};
 use std::sync::{Mutex, OnceLock};
 
-use photocraft_color::{PixelFormat, SampleType};
+use photocraft_color::{ColorMode, PixelFormat, SampleType};
 use photocraft_doc::vector::ShapeLayer;
+use photocraft_doc::{Fill, Pattern};
 use photocraft_geom::Rect;
 use photocraft_raster::Surface;
 
 /// (fill, stroke), last use, and the shape's pixels the stroke was fitted to (kept alive so
 /// their tile addresses, part of the key, can't be reused).
-type Entry = ((Surface, Surface), u64, Option<Surface>);
+type Entry = ((Surface, Surface), u64, Option<Surface>, Vec<Pattern>);
 
 fn cache() -> &'static Mutex<(HashMap<u64, Entry>, u64)> {
     static C: OnceLock<Mutex<(HashMap<u64, Entry>, u64)>> = OnceLock::new();
@@ -21,24 +22,26 @@ fn cache() -> &'static Mutex<(HashMap<u64, Entry>, u64)> {
 }
 
 const CAPACITY: usize = 32;
-/// Most pixel bytes the split parts may hold (#4530): each entry is two canvas-sized surfaces,
-/// so 32 of them at 24 MP could hold several GB outside every memory budget.
+/// Most pixel bytes held by split parts and their pinned patterns (#4530).
 #[cfg(not(test))]
 const BUDGET: usize = 256 << 20;
 /// Small under test, so a few 512² shapes exercise the eviction.
 #[cfg(test)]
 const BUDGET: usize = 8 << 20;
 
-/// Pixel bytes of one entry's (fill, stroke) parts (the pinned shape pixels are the document's).
-fn entry_bytes(parts: &(Surface, Surface)) -> usize {
-    [&parts.0, &parts.1].iter().map(|s| s.tiles().map(|(_, t)| t.bytes().len()).sum::<usize>()).sum()
+/// Pixel bytes held by one entry, including pinned source tiles. Shared pattern tiles count once
+/// per entry, conservatively matching their retained lifetime without cross-entry bookkeeping.
+fn entry_bytes(parts: &(Surface, Surface), patterns: &[Pattern]) -> usize {
+    let part_bytes = [&parts.0, &parts.1].iter().flat_map(|s| s.tiles().map(|(_, t)| t.bytes().len())).fold(0usize, usize::saturating_add);
+    let pattern_bytes = patterns.iter().flat_map(|p| p.surface.tiles().map(|(_, t)| t.bytes().len())).fold(0usize, usize::saturating_add);
+    part_bytes.saturating_add(pattern_bytes)
 }
 
-/// Drop every cached split (Edit › Purge › All), returning the parts' pixel bytes. Callers may
-/// still hold returned surfaces, so this is not a measure of freed process memory.
+/// Drop every cached split (Edit › Purge › All), returning accounted pixel bytes. Callers may
+/// still hold returned surfaces and patterns, so this is not a measure of freed p

#5270: Layer Style: Gradient Overlay takes any number of colour stops (#5177)

crates/engine/src/layer_style.rs, crates/ui-egui/src/control.rs, crates/ui-egui/src/dialogs/layer_style.rs, crates/ui-egui/src/dialogs/layer_style/gradient_field.rs, docs/control-protocol.md

diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs
index fbbc949e2..23b0e4f99 100644
--- a/crates/engine/src/layer_style.rs
+++ b/crates/engine/src/layer_style.rs
@@ -64,9 +64,16 @@ fn gradient_stops(p: &Value, from: Color, to: Color) -> Vec<(f32, Color)> {
         None => stops,
     }
 }
+/// The gradient of a stroke or overlay's params. Bad `stops` are ignored here, as bad values of
+/// any other key are; the commands refuse them first ([`check_gradient_params`]).
 fn gradient(p: &Value) -> Gradient {
+    let stops = match stops_param(p, "layer.layerStyle").ok().flatten() {
+        Some(stops) => stops,
+        None => gradient_stops(p, color(p, "from", [0.0; 3]), color(p, "to", [1.0; 3])),
+    };
     Gradient {
-        stops: gradient_stops(p, color(p, "from", [0.0; 3]), color(p, "to", [1.0; 3])),
+        stops,
+        opacity_stops: opacity_stops_param(p, "layer.layerStyle").ok().flatten().unwrap_or_default(),
         style: gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")),
         angle: f(p, "angle", 90.0),
         scale: f(p, "scale", 100.0) / 100.0,
@@ -75,6 +82,61 @@ fn gradient(p: &Value) -> Gradient {
     }
 }
 
+/// `Err` when `stops` or `opacityStops` are present but malformed, so a command refuses them
+/// instead of silently keeping the old gradient.
+pub fn check_gradient_params(p: &Value, cmd: &str) -> Result<()> {
+    stops_param(p, cmd)?;
+    opacity_stops_param(p, cmd)?;
+    Ok(())
+}
+
+/// Most stops a gradient parameter may carry.
+const MAX_STOPS: usize = 256;
+
+/// Explicit colour stops (#5177): `"stops": [[position 0..1, "#rrggbb" | [r, g, b]], …]`, at
+/// least 2 and at most [`MAX_STOPS`], returned sorted by position. `None` without the key.
+fn stops_param(p: &Value, cmd: &str) -> Result<Option<Vec<(f32, Color)>>> {
+    let Some(v) = p.get("stops") else { return Ok(None) };
+    let bad = |msg: String| EngineError::BadParams { cmd: cmd.into(), msg };
+    let a = v.as_array().ok_or_else(|| bad("\"stops\" must be an array of [position, colour]".into()))?;
+    if a.len() < 2 {
+        return Err(bad("\"stops\" needs at least 2 stops".into()));
+    }
+    if a.len() > MAX_STOPS {
+        return Err(bad(format!("\"stops\": at most {MAX_STOPS} stops")));
+    }
+    let mut stops = Vec::with_capacity(a.len());
+    for (i, s) in a.iter().enumerate() {
+        let t = s.get(0).and_then(Value::as_f64).filter(|t| t.is_finite()).ok_or_else(|| bad(format!("\"stops\"[{i}] must be [position 0..1, colour]")))?;
+        let c = s.get(1).and_then(|c| color_of(&json!({"c": c}), "c")).ok_or_else(|| bad(format!("\"stops\"[{i}]: no colour (\"#rrggbb\" or [r, g, b])")))?;
+        stops.push((t.clamp(0.0, 1.0) as f32, c));
+    }
+    stops.sort_by(|a, b| a.0.total_cmp(&b.0));
+    Ok(Some(stops))
+}
+
+/// Explicit transparency stops: `"opacityStops": [[position 0..1, opacity 0..100], …]`; an empty
+/// array means fully opaque. `None` without the key.
+fn opacity_stops_param(p: &Value, cmd: &str) -> Result<Option<Vec<(f32, f32)>>> {
+    let Some(v) = p.get("opacityStops") else { return Ok(None) };
+    let bad = |msg: String| EngineError::BadParams { cmd: cmd.into(), msg };
+    let a = v.as_array().ok_or_else(|| bad("\"opacityStops\" must be an array of [position, opacity]".into()))?;
+    if a.len() > MAX_STOPS {
+        return Err(bad(format!("\"opacityStops\": at most {MAX_STOPS} stops")));
+    }
+    if a.len() == 1 {
+        return E

#5176: Play CopyCat, AJ and Line and Color action sets; keep patterns across restarts; Shift Edge scale

crates/engine/src/actions_cmds.rs, crates/engine/src/atn_cmds.rs, crates/engine/src/atn_cmds/tests.rs, crates/engine/src/cmd.rs, crates/engine/src/cmd/history_cmds.rs, crates/engine/src/cmd/pattern_cmds.rs, crates/engine/src/lib.rs, crates/engine/src/preset_store.rs, crates/engine/src/preset_store/tests.rs, crates/engine/src/smart_cmds.rs, crates/ui-egui/src/actions.rs, crates/ui-egui/src/panels/preset_panels.rs, docs/file-format-parity.md, docs/gaps.md, docs/target-app-parity.md

diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs
index d881a6112..0eb136f23 100644
--- a/crates/engine/src/actions_cmds.rs
+++ b/crates/engine/src/actions_cmds.rs
@@ -128,6 +128,14 @@ pub fn shell_save_command(id: &str) -> bool {
     matches!(id, "file.save" | "file.saveAs" | "file.saveACopy")
 }
 
+/// Save in an Edit Contents document writes it back into its smart object, as File › Save does
+/// there: the step needs no file and plays headless too.
+pub fn saves_smart_contents(s: &Session, id: &str) -> bool {
+    id == "file.save" && s.is_enabled(SAVE_CONTENTS)
+}
+
+pub const SAVE_CONTENTS: &str = "layer.smartObjects.saveContents";
+
 /// View-only menu commands recorded by the desktop shell. Headless playback reports
 /// them as unsupported instead of silently dropping them; Batch and droplets skip them.
 pub fn shell_view_command(id: &str) -> bool {
@@ -317,7 +325,8 @@ fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option<V
             failed = Some(json!({"step": i, "id": id, "error": e.to_string()}));
             break;
         }
-        match s.execute(id, params.clone()) {
+        let result = if saves_smart_contents(s, id) { s.execute(SAVE_CONTENTS, json!({})) } else { s.execute(id, params.clone()) };
+        match result {
             Ok(value) => {
                 if let Some(error) = nested_failure(id, &value) {
                     failed = Some(json!({"step": i, "id": id, "error": error}));
@@ -692,6 +701,30 @@ mod tests {
         s.active().unwrap().doc.layers[layer].surface().unwrap().pixel(x, y)
     }
 
+    /// CopyCat's shape: Edit Contents, change the contents, Save. The Save step has no file to
+    /// write: it updates the smart object, headless too.
+    #[test]
+    fn save_in_edit_contents_updates_the_smart_object() {
+        let mut s = Session::new();
+        s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
+        s.execute("layer.new.layer", json!({})).unwrap();
+        s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap();
+        s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
+        s.execute("layer.smartObjects.editContents", json!({})).unwrap();
+        let steps = vec![("edit.fill".to_string(), json!({"contents": "color", "color": "#0000ff"})), ("file.save".to_string(), json!({}))];
+        s.actions.list.push(Action::new("Edit and save", steps));
+        let played = s.execute("actions.play", json!({"action": "Edit and save"})).unwrap();
+        assert!(played.get("failed").is_none(), "{played}");
+        s.set_active(0);
+        let doc = &s.active().unwrap().doc;
+        let flat = crate::file_cmds::flattened(doc, doc.pixel_format());
+        let p = flat.pixel(2, 2);
+        assert!(p[0] < 0.01 && p[2] > 0.99, "the smart object shows the saved contents: {p:?}");
+        // With no Edit Contents document, Save stays the shell's (it needs the document's file).
+        s.actions.list.push(Action::new("Save", vec![("file.save".to_string(), json!({}))]));
+        assert!(s.execute("actions.play", json!({"action": "Save"})).unwrap().get("failed").is_some());
+    }
+
     #[test]
     fn unchecked_steps_are_skipped_and_follow_move_and_delete() {
         let mut s = Session::new();
diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs
index ebeda4868..6d2793a22 100644
--- a/crates/engine/src/atn_cmds.rs
+++ b/crates/engin

#5090: Actions: add editable PhotoCraft Essentials starter presets

crates/engine/src/actions_cmds.rs, crates/engine/src/lib.rs, crates/engine/src/preset_store.rs, crates/ui-egui/src/actions.rs

diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs
index d881a6112..445a67675 100644
--- a/crates/engine/src/actions_cmds.rs
+++ b/crates/engine/src/actions_cmds.rs
@@ -78,6 +78,9 @@ impl Action {
 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
 pub struct ActionsFile {
     pub version: u32,
+    /// Starter set last seeded. Older stores default to zero; deletions after seeding persist.
+    #[serde(default)]
+    pub defaults_version: u32,
     pub actions: Vec<Action>,
 }
 
@@ -1386,8 +1389,8 @@ mod tests {
         let mut t = Session::new();
         t.attach_preset_store(crate::preset_store::open_dir(&dir));
         let listed = t.execute("actions.list", json!({})).unwrap();
-        assert_eq!(listed["actions"][0]["name"], "Kept");
-        assert_eq!(listed["actions"][0]["steps"], 1);
+        let kept = listed["actions"].as_array().unwrap().iter().find(|a| a["name"] == "Kept").unwrap();
+        assert_eq!(kept["steps"], 1);
         assert_eq!(t.execute("actions.get", json!({"action": "Kept"})).unwrap()["steps"][0][0], "layer.new.layer");
 
         // An action created before the store finishes loading is kept, and a different one on
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index d2aecf37d..5eb66d860 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -7,6 +7,7 @@
 #![forbid(unsafe_code)]
 #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
 
+pub mod action_defaults;
 pub mod actions_cmds;
 pub mod actions_edit_cmds;
 pub mod adjust_cmds;
@@ -17,6 +18,7 @@ pub mod analysis_cmds;
 pub mod artboard_cmds;
 pub mod atn_cmds;
 pub mod automate_cmds;
+pub mod background_blur_cmds;
 pub mod background_cmds;
 pub mod brush_cmds;
 pub mod brush_key_cmds;
diff --git a/crates/engine/src/preset_store.rs b/crates/engine/src/preset_store.rs
index 83cb16a83..a52d33827 100644
--- a/crates/engine/src/preset_store.rs
+++ b/crates/engine/src/preset_store.rs
@@ -548,6 +548,8 @@ pub struct PresetStore {
     index: Option<IndexFile>,
     /// [`crate::actions_cmds::ActionState::rev`] last written (or loaded).
     actions_rev: u64,
+    /// Starter set last offered to this store, independent of later user deletions or renames.
+    actions_defaults_version: u32,
     /// `actions.json` exists but failed to load: left on disk untouched, so it is never written.
     actions_skipped: bool,
     warnings: Vec<String>,
@@ -643,7 +645,7 @@ pub fn open(backend: Box<dyn PresetBackend>) -> Opened {
 
     let loaded_actions = load_actions(backend.as_ref(), &mut warnings);
     let actions_skipped = loaded_actions.is_none();
-    let actions = loaded_actions.unwrap_or_default();
+    let (actions, actions_defaults_version) = loaded_actions.map(|f| (f.actions, f.defaults_version)).unwrap_or_default();
     let mut store = PresetStore {
         backend,
         synced_rev: None,
@@ -652,6 +654,7 @@ pub fn open(backend: Box<dyn PresetBackend>) -> Opened {
         tips: HashMap::new(),
         index: None,
         actions_rev: 0,
+        actions_defaults_version,
         actions_skipped,
         warnings: Vec::new(),
         cache: Mutex::new(TipCache { limit: TIP_CACHE_BYTES, ..Default::default() }),
@@ -715,17 +718,17 @@ pub fn open(backend: Box<dyn PresetBackend>) -> Opened {
 
 /// `actions.json`: a missing file is an empty list. Anything else unreadable is a warning.
 /// The saved actio

#5082: Mixer Brush Alt-click loads reservoir while preserving foreground

crates/engine/src/cmd/brush_cmds.rs, crates/paint/src/mixer.rs, crates/ui-egui/src/canvas.rs, crates/ui-egui/src/input/alt_grab.rs

diff --git a/crates/paint/src/mixer.rs b/crates/paint/src/mixer.rs
index afb8092cd..e4668638e 100644
--- a/crates/paint/src/mixer.rs
+++ b/crates/paint/src/mixer.rs
@@ -44,6 +44,9 @@ pub struct MixerState {
     pub amount: f32,
     /// Colour picked up from the canvas (straight RGBA), None = clean.
     pub pickup: Option<[f32; 4]>,
+    /// The foreground colour in effect when the reservoir was loaded from the canvas
+    /// (`load_sampled`), None when it holds the foreground itself.
+    pub sampled_over: Option<[f32; 4]>,
 }
 
 impl MixerState {
@@ -51,6 +54,17 @@ impl MixerState {
     pub fn load(&mut self, color: [f32; 4]) {
         self.reservoir = Some(color);
         self.amount = 1.0;
+        self.sampled_over = None;
+    }
+    /// Load the reservoir with a colour sampled from the canvas (⌥-click) while `foreground` is
+    /// the foreground colour; `holds_sample` stays true until the foreground changes.
+    pub fn load_sampled(&mut self, color: [f32; 4], foreground: [f32; 4]) {
+        self.load(color);
+        self.sampled_over = Some(foreground);
+    }
+    /// Whether the reservoir holds a canvas sample taken while `foreground` was the foreground.
+    pub fn holds_sample(&self, foreground: [f32; 4]) -> bool {
+        self.reservoir.is_some() && self.sampled_over == Some(foreground)
     }
     /// Clean the brush (drop the picked-up colour).
     pub fn clean(&mut self) {
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 91644b5f7..19e1737f8 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -4017,7 +4017,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
                 t if resizing && crate::brush_resize::applies(t) => egui::CursorIcon::None,
                 // ⌥ turns a painting tool into the Eyedropper (`alt_eyedropper`): its cursor too,
                 // unless Preferences › Cursors › Other Cursors asks for the precise crosshair.
-                t if app.alt_sampling || (app.drag.is_none() && alt_samples(t, crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers)))) => {
+                t if app.alt_sampling || (app.drag.is_none() && alt_picks(t, crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers)))) => {
                     // While it samples (button held), the comparison ring replaces the cursor (#213).
                     if let Some(icon) = eyedropper_ring(app, &painter, &xf, p, response.is_pointer_button_down_on()) {
                         icon
@@ -5056,6 +5056,18 @@ pub(crate) fn alt_samples(tool: Tool, mods: egui::Modifiers) -> bool {
     mods.alt && !mods.ctrl && matches!(tool, Tool::Brush | Tool::Pencil | Tool::Gradient | Tool::PaintBucket)
 }
 
+/// ⌥ with the Mixer Brush loads its reservoir from the canvas (`paint.mixerLoad`), as in
+/// Photoshop, instead of setting the foreground colour (#4873). It shows the Eyedropper cursor.
+pub(crate) fn alt_loads_mixer(tool: Tool, mods: egui::Modifiers) -> bool {
+    // Control+Alt is the brush-resize drag (`brush_resize`), not sampling.
+    mods.alt && !mods.ctrl && tool == Tool::MixerBrush
+}
+
+/// Tools whose ⌥ press is a sample: the Eyedropper cursor shows and no stroke starts.
+pub(crate) fn alt_picks(tool: Tool, mods: egui::Modifiers) -> bool {
+    alt_samples(tool, mods) || alt_loads_mixer(tool, mods)
+}
+
 /// Preferences › Cursors › Other Cursors asks for the precise crosshair, or Caps Lock swaps the
 /// standard cu

#5010: Layer Style: Photoshop's layout (three columns, OK/Cancel/New Style/Preview on the right, one-line rows) [depends on #4938]

crates/ui-egui/src/dialogs.rs, crates/ui-egui/src/dialogs/layer_style.rs, crates/ui-egui/src/panels.rs, crates/ui-egui/src/widgets.rs

diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 013c79e82..2b577b32d 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -108,11 +108,42 @@ fn resizable_dialog(fields: &serde_json::Map<String, Value>) -> bool {
     fields.contains_key("__web") || crate::prefs_ui::is_preferences(fields)
 }
 
+/// How far the title band reaches past the title into the frame's padding (#1921).
+const TITLE_REACH: f32 = 7.0;
+
+/// The size of a dialog whose body fills the size it is given, rather than setting it.
+struct Sizing {
+    /// Where egui keeps the size.
+    id: egui::Id,
+    /// At most the window, less a margin.
+    default: egui::Vec2,
+    /// Also the floor on a window smaller than that.
+    min: egui::Vec2,
+    /// A grip in the bottom-right corner resizes it.
+    resizable: bool,
+    /// Outline the sized area.
+    stroke: bool,
+}
+
+/// The dialogs sized here: the web page viewer and Preferences (resizable), and Layer Style, which
+/// keeps one size on every page like Photoshop's (not resizable there either); its effect list and
+/// settings page scroll when they are taller, so OK and Cancel stay on a short screen.
+fn sizing_of(d: &Dialog, id: egui::Id) -> Option<Sizing> {
+    let id = id.with("resize");
+    if resizable_dialog(&d.fields) {
+        Some(Sizing { id, default: egui::vec2(960.0, 900.0), min: egui::vec2(560.0, 400.0), resizable: true, stroke: true })
+    } else if d.kind == DialogKind::LayerStyle {
+        // Photoshop's shape: wide and short, with OK and Cancel in a column on the right.
+        Some(Sizing { id, default: egui::vec2(800.0, 550.0), min: egui::vec2(640.0, 360.0), resizable: false, stroke: false })
+    } else {
+        None
+    }
+}
+
 fn show_modal<T>(
     modal: egui::Modal,
     ctx: &egui::Context,
-    id: egui::Id,
-    resizable: bool,
+    sizing: Option<&Sizing>,
     interactive: bool,
     contents: impl FnOnce(&mut egui::Ui) -> T,
 ) -> egui::ModalResponse<T> {
@@ -121,14 +152,17 @@ fn show_modal<T>(
         if !interactive {
             ui.disable();
         }
-        if resizable {
-            let max = (ctx.content_rect().size() - egui::vec2(40.0, 40.0)).max(egui::vec2(560.0, 400.0));
-            egui::Resize::default()
-                .id(id.with("resize"))
-                .default_size(egui::vec2(960.0, 900.0).min(max))
-                .min_size(egui::vec2(560.0, 400.0))
-                .max_size(max)
-                .show(ui, contents)
+        if let Some(s) = sizing {
+            let max = (ctx.content_rect().size() - egui::vec2(40.0, 40.0)).max(s.min);
+            let resize = egui::Resize::default().id(s.id).with_stroke(s.stroke);
+            let resize =
+                if s.resizable { resize.default_size(s.default.min(max)).min_size(s.min).max_size(max) } else { resize.fixed_size(s.default.min(max)) };
+            resize.show(ui, |ui| {
+                // The Resize clips to its area, which cut off the title band's reach into the
+                // frame's margin (#1921): only the title band is drawn or hit there.
+                ui.set_clip_rect(ui.clip_rect().expand(TITLE_REACH + 1.0));
+                contents(ui)
+            })
         } else {
             contents(ui)
         }
@@ -207,7 +241,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
         // Photoshop doesn't dim the window behind dialogs: previews must be judged at true contrast.
         let web

#4993: Prevent control-channel engine commands from editing behind modal dialogs

crates/ui-egui/src/control.rs

diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs
index e797b27cc..4fc782442 100644
--- a/crates/ui-egui/src/control.rs
+++ b/crates/ui-egui/src/control.rs
@@ -407,18 +407,16 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
         "engine.execute" | "ui.menu.invoke" => {
             let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
             let params = p.get("params").cloned().unwrap_or(json!({}));
-            // A control-channel menu click must respect the same modal gate as the native menu.
-            // Camera Raw's documented `ui` requests operate its already-open dialog rather
-            // than click a menu. Only that continuation may pass its own modal gate.
-            // engine.execute is deliberately not subject to the UI's menu-click semantics.
+            // Both command entry points must respect open modal dialogs; Camera Raw's
+            // documented `ui` continuation acts on that dialog rather than the document.
             let camera_raw_continuation = id == "filter.cameraRaw"
                 && app.camera_raw.is_some()
                 && params.as_object().is_some_and(|fields| fields.len() == 1 && fields.get("ui").is_some_and(Value::is_object))
                 && app.ui.dialogs.is_empty()
                 && app.discard.is_none()
                 && !crate::crop_ui::blocks(app, id);
-            if req.method == "ui.menu.invoke" && !camera_raw_continuation && !crate::menus::modal_allows(app, id) {
-                return err("menu command is unavailable while a modal dialog is open");
+            if !camera_raw_continuation && !crate::menus::modal_allows(app, id) {
+                return err("command is unavailable while a modal dialog is open");
             }
             if let Some(authorize) = app.services.automation_command.as_ref()
                 && let Err(error) = authorize(id, &params)
@@ -1842,6 +1840,31 @@ mod tests {
         assert!(app.session.active().unwrap().revision > revision);
     }
 
+    #[test]
+    fn engine_execute_cannot_change_document_behind_adjustment_dialog() {
+        let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
+        let ctx = egui::Context::default();
+        app.run("file.new", json!({"width": 60, "height": 40, "background": "#3366cc"})).unwrap();
+        let opened = call(&mut app, &ctx, "ui.menu.invoke", json!({"id": "image.adjustments.hueSaturation"}));
+        assert_eq!(opened["ok"], true, "{opened}");
+        let dialog = opened["result"]["dialog"].as_u64().unwrap();
+        let revision = app.session.active().unwrap().revision;
+        let fields = call(&mut app, &ctx, "ui.inspect", json!({}))["result"]["dialogs"][0]["fields"].clone();
+
+        for method in ["engine.execute", "ui.menu.invoke"] {
+            let blocked = call(&mut app, &ctx, method, json!({"command": "image.adjustments.invert"}));
+            assert_eq!(blocked["ok"], false, "{method}: {blocked}");
+            assert_eq!(app.session.active().unwrap().revision, revision, "{method} changed the document");
+            let snapshot = call(&mut app, &ctx, "ui.inspect", json!({}));
+            assert_eq!(snapshot["result"]["dialogs"][0]["id"], dialog);
+            assert_eq!(snapshot["result"]["dialogs"][0]["fields"], fields);
+        }
+
+        assert_eq!(call(&mut app, &ctx, "ui.dialog.cancel", json!({"dialog": dialog}))["ok"]

#4992: Preserve Dissolve coverage when compositing clipped layers

crates/compose/src/lib.rs, crates/compose/src/tests.rs, crates/gpu/src/compose.wgsl, crates/gpu/tests/it/parity.rs, crates/ui-egui/src/rotate_view.rs, crates/ui-egui/src/wheel_nav.rs

diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs
index 8e88675fd..3fbf3c449 100644
--- a/crates/compose/src/lib.rs
+++ b/crates/compose/src/lib.rs
@@ -1736,14 +1736,23 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
     let Some(content) = render_content(layer, rect, cx) else { return };
     let opacity = layer.opacity * layer.fill_opacity;
     let gamma = text_gamma(layer);
+    let width = rect.width() as usize;
     for (i, p) in base.px.iter_mut().enumerate() {
         let alpha = p[3];
         if alpha <= 0.0 {
             continue;
         }
-        let s = content.px[i];
+        let mut s = content.px[i];
         // Blend as if the base were opaque, then keep the base's alpha.
-        let r = blend::composite_gamma(layer.blend, [p[0], p[1], p[2], 1.0], s, opacity, gamma);
+        // Dissolve needs a binary coverage decision before compositing.
+        let r = if layer.blend == BlendMode::Dissolve {
+            let x = rect.x0 + (i % width) as i32;
+            let y = rect.y0 + (i / width) as i32;
+            s[3] = if dissolve_noise(x, y) < s[3] * opacity { 1.0 } else { 0.0 };
+            blend::composite(BlendMode::Normal, [p[0], p[1], p[2], 1.0], s, 1.0)
+        } else {
+            blend::composite_gamma(layer.blend, [p[0], p[1], p[2], 1.0], s, opacity, gamma)
+        };
         *p = [r[0], r[1], r[2], alpha];
     }
 }
diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs
index 6680f11a9..27ddbcc05 100644
--- a/crates/compose/src/tests.rs
+++ b/crates/compose/src/tests.rs
@@ -316,6 +316,35 @@ fn clipping_mask_restricts_to_base_alpha() {
     assert!(close4(px(&d, 6, 1), [1.0; 4]), "outside base: clipped layer hidden");
 }
 
+/// #4921: Dissolve coverage must survive clipping and use Opacity × Fill.
+#[test]
+fn clipped_dissolve_is_binary_and_preserves_the_base_shape() {
+    let mut d = doc_white(40, 12);
+    d.layers.push(solid_layer("base", Rect::new(0, 0, 20, 12), [0.0, 0.0, 1.0, 1.0]));
+    let mut clip = solid_layer("clip", d.bounds(), [1.0, 0.0, 0.0, 1.0]);
+    clip.clipped = true;
+    clip.blend = BlendMode::Dissolve;
+    clip.opacity = 0.6;
+    clip.fill_opacity = 0.5;
+    d.layers.push(clip);
+    let mut red = 0;
+    let mut blue = 0;
+    for y in 0..12 {
+        for x in 0..20 {
+            let p = px(&d, x, y);
+            if close4(p, [1.0, 0.0, 0.0, 1.0]) {
+                red += 1;
+            } else {
+                assert!(close4(p, [0.0, 0.0, 1.0, 1.0]), "Dissolve must not interpolate: {p:?} at ({x},{y})");
+                blue += 1;
+            }
+        }
+        assert!(close4(px(&d, 30, y), [1.0; 4]), "outside the base the clip is invisible");
+    }
+    assert!(red > 0 && blue > 0, "Dissolve must expose both source and base: {red}/{blue}");
+    assert!((red as f32 / (red + blue) as f32 - 0.3).abs() < 0.1, "coverage should follow 60% opacity × 50% fill: {red}/{blue}");
+}
+
 #[test]
 fn hidden_base_hides_clipping_group() {
     let mut d = doc_white(4, 4);
@@ -328,6 +357,33 @@ fn hidden_base_hides_clipping_group() {
     assert!(close4(px(&d, 1, 1), [1.0; 4]));
 }
 
+#[test]
+fn clipped_dissolve_matches_whole_render_at_offset_regions_and_depths() {
+    for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
+        let mut d = Document::new("coverage", Size::new(32, 18), ColorMode::Rgb, fmt.sample);
+        let bounds = Rect::new(-3, -2, 32, 18);
+        let mut base = Layer::rast

#4938: Layer Style: one dialog size on every page, and it fits short screens

crates/ui-egui/src/dialogs.rs, crates/ui-egui/src/dialogs/layer_style.rs

diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 013c79e82..e40288aab 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -108,11 +108,42 @@ fn resizable_dialog(fields: &serde_json::Map<String, Value>) -> bool {
     fields.contains_key("__web") || crate::prefs_ui::is_preferences(fields)
 }
 
+/// How far the title band reaches past the title into the frame's padding (#1921).
+const TITLE_REACH: f32 = 7.0;
+
+/// The size of a dialog whose body fills the size it is given, rather than setting it.
+struct Sizing {
+    /// Where egui keeps the size.
+    id: egui::Id,
+    /// At most the window, less a margin.
+    default: egui::Vec2,
+    /// Also the floor on a window smaller than that.
+    min: egui::Vec2,
+    /// A grip in the bottom-right corner resizes it.
+    resizable: bool,
+    /// Outline the sized area.
+    stroke: bool,
+}
+
+/// The dialogs sized here: the web page viewer and Preferences (resizable), and Layer Style, which
+/// keeps one size on every page like Photoshop's (not resizable there either); its effect list and
+/// settings page scroll when they are taller, so OK and Cancel stay on a short screen.
+fn sizing_of(d: &Dialog, id: egui::Id) -> Option<Sizing> {
+    let id = id.with("resize");
+    if resizable_dialog(&d.fields) {
+        Some(Sizing { id, default: egui::vec2(960.0, 900.0), min: egui::vec2(560.0, 400.0), resizable: true, stroke: true })
+    } else if d.kind == DialogKind::LayerStyle {
+        // Room for the tallest page (Bevel & Emboss) on a 900-point screen.
+        Some(Sizing { id, default: egui::vec2(600.0, 760.0), min: egui::vec2(540.0, 380.0), resizable: false, stroke: false })
+    } else {
+        None
+    }
+}
+
 fn show_modal<T>(
     modal: egui::Modal,
     ctx: &egui::Context,
-    id: egui::Id,
-    resizable: bool,
+    sizing: Option<&Sizing>,
     interactive: bool,
     contents: impl FnOnce(&mut egui::Ui) -> T,
 ) -> egui::ModalResponse<T> {
@@ -121,14 +152,17 @@ fn show_modal<T>(
         if !interactive {
             ui.disable();
         }
-        if resizable {
-            let max = (ctx.content_rect().size() - egui::vec2(40.0, 40.0)).max(egui::vec2(560.0, 400.0));
-            egui::Resize::default()
-                .id(id.with("resize"))
-                .default_size(egui::vec2(960.0, 900.0).min(max))
-                .min_size(egui::vec2(560.0, 400.0))
-                .max_size(max)
-                .show(ui, contents)
+        if let Some(s) = sizing {
+            let max = (ctx.content_rect().size() - egui::vec2(40.0, 40.0)).max(s.min);
+            let resize = egui::Resize::default().id(s.id).with_stroke(s.stroke);
+            let resize =
+                if s.resizable { resize.default_size(s.default.min(max)).min_size(s.min).max_size(max) } else { resize.fixed_size(s.default.min(max)) };
+            resize.show(ui, |ui| {
+                // The Resize clips to its area, which cut off the title band's reach into the
+                // frame's margin (#1921): only the title band is drawn or hit there.
+                ui.set_clip_rect(ui.clip_rect().expand(TITLE_REACH + 1.0));
+                contents(ui)
+            })
         } else {
             contents(ui)
         }
@@ -207,7 +241,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
         // Photoshop doesn't dim the window behind dialogs: previews must be judged at true contrast.
         let web = fields.cont

#4890: Add scrollbar visibility preferences (#3262)

crates/engine/src/prefs.rs, crates/engine/src/prefs/tests.rs, crates/ui-egui/src/canvas.rs, crates/ui-egui/src/prefs_ui.rs, crates/ui-egui/src/theme.rs, docs/gaps.md, docs/scorecard.md, docs/ui-parity.md

diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs
index 378f71c18..247ad5e13 100644
--- a/crates/engine/src/prefs.rs
+++ b/crates/engine/src/prefs.rs
@@ -83,6 +83,7 @@ choice!(
     LinuxDisplayServer { Auto = "auto", X11 = "x11" } default Auto
 );
 choice!(UiFontSize { Tiny = "tiny", Small = "small", Medium = "medium", Large = "large" } default Small);
+choice!(ScrollbarVisibility { Default = "default", Always = "always", AutoHide = "autoHide", Hidden = "hidden" } default Default);
 choice!(LogDestination { Metadata = "metadata", TextFile = "textFile", Both = "both" } default Metadata);
 choice!(LogDetail { SessionsOnly = "sessionsOnly", Concise = "concise", Detailed = "detailed" } default Concise);
 choice!(Ask { Always = "always", Never = "never", Ask = "ask" } default Always);
@@ -265,6 +266,8 @@ pub struct Interface {
     /// Command ids and the control protocol stay English.
     pub language: String,
     pub ui_font_size: UiFontSize,
+    /// Default follows the theme; other modes apply to panel and canvas scrollbars (#3262).
+    pub scrollbar_visibility: ScrollbarVisibility,
     pub show_channels_in_color: bool,
     pub dynamic_color_sliders: bool,
     /// Draw menu item colours set with Edit › Menus.
@@ -302,6 +305,7 @@ impl Default for Interface {
             ui_scale: UiScale::Auto,
             language: "auto".into(),
             ui_font_size: UiFontSize::Small,
+            scrollbar_visibility: ScrollbarVisibility::Default,
             show_channels_in_color: false,
             dynamic_color_sliders: true,
             show_menu_colors: true,
@@ -1101,6 +1105,7 @@ pub fn choices(path: &str) -> Option<&'static [&'static str]> {
         "interface.canvasBorder" => CanvasBorder::NAMES,
         "interface.uiScale" => UiScale::NAMES,
         "interface.uiFontSize" => UiFontSize::NAMES,
+        "interface.scrollbarVisibility" => ScrollbarVisibility::NAMES,
         "historyLog.destination" => LogDestination::NAMES,
         "historyLog.detail" => LogDetail::NAMES,
         "fileHandling.imagePreviews" | "fileHandling.maximizePsdCompatibility" => Ask::NAMES,
diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs
index 6bb34a970..704e5cc43 100644
--- a/crates/engine/src/prefs/tests.rs
+++ b/crates/engine/src/prefs/tests.rs
@@ -22,6 +22,26 @@ fn defaults_match_photoshop() {
     }
 }
 
+#[test]
+fn scrollbar_visibility_round_trips_and_old_preferences_keep_the_theme_default() {
+    let old = serde_json::from_value::<Preferences>(json!({"interface": {"uiFontSize": "small"}})).unwrap();
+    assert_eq!(old.interface.scrollbar_visibility, ScrollbarVisibility::Default);
+    assert_eq!(choices("interface.scrollbarVisibility"), Some(ScrollbarVisibility::NAMES));
+    let mut s = Session::new();
+    for mode in ScrollbarVisibility::NAMES {
+        s.execute("prefs.set", json!({"values": {"interface.scrollbarVisibility": mode}})).unwrap();
+        let saved = s.prefs().to_json();
+        let loaded: Preferences = serde_json::from_value(saved.clone()).unwrap();
+        assert_eq!(loaded.interface.scrollbar_visibility, s.prefs().interface.scrollbar_visibility);
+        assert_eq!(saved["interface"]["scrollbarVisibility"], json!(mode));
+    }
+    let before = s.prefs().to_json();
+    assert!(s.execute("prefs.set", json!({"values": {"interface.scrollbarVisibility": "sometimes"}})).is_err());
+    assert_eq!(s.prefs().to_json(), before);
+    s.execute("prefs.reset", json!({"

#4783: Fix/4758 type extreme finite geometry 20261013s1215

crates/io/src/blocks.rs, crates/io/tests/it/psd_type_bounds.rs, crates/text/src/psd.rs

diff --git a/crates/io/src/blocks.rs b/crates/io/src/blocks.rs
index 87fda102f..5541fd207 100644
--- a/crates/io/src/blocks.rs
+++ b/crates/io/src/blocks.rs
@@ -578,6 +578,11 @@ pub fn parse_tysh(data: &[u8]) -> Option<(String, Affine)> {
     for (i, v) in m.iter_mut().enumerate() {
         *v = f(2 + i * 8)?;
     }
+    // This best-effort fallback cannot bypass the text parser's geometry safety gate.
+    // Unusable matrices use identity instead of corrupting canvas or panel rectangles.
+    if !photocraft_text::psd::usable_tysh_transform(&m) {
+        return None;
+    }
     // text version (2) at 50, descriptor version (4) at 52, descriptor at 56.
     let rest = data.get(52..)?;
     let text = parse_prefix_versioned(rest).and_then(|d| match d.get("Txt ") {
@@ -1247,6 +1252,20 @@ mod more_tests {
         assert!(effects_enabled(&[0, 0, 0, 0]));
     }
 
+    #[test]
+    fn tysh_fallback_cannot_reintroduce_unusable_finite_or_nonfinite_transform() {
+        let mut tysh = vec![0u8; 52];
+        tysh[..2].copy_from_slice(&1u16.to_be_bytes());
+        for (i, value) in [1.0f64, 0.0, 0.0, 1.0, 30.0, 40.0].iter().enumerate() {
+            tysh[2 + 8 * i..10 + 8 * i].copy_from_slice(&value.to_be_bytes());
+        }
+        assert!(parse_tysh(&tysh).is_some(), "normal matrices still import");
+        for value in [1e62f64, -1e62, f64::INFINITY, f64::NAN] {
+            tysh[2..10].copy_from_slice(&value.to_be_bytes());
+            assert!(parse_tysh(&tysh).is_none(), "invalid matrix {value} cannot reach UI");
+        }
+    }
+
     #[test]
     fn tysh_garbage_is_none() {
         assert!(parse_tysh(&[0; 10]).is_none());
diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs
index 8c502c75a..b9c40dac4 100644
--- a/crates/text/src/psd.rs
+++ b/crates/text/src/psd.rs
@@ -31,12 +31,22 @@ pub struct TySh {
 }
 
 /// Parses a `TySh` block.
+/// Return whether a TySh affine can safely be passed into text layout and UI geometry.
+pub fn usable_tysh_transform(matrix: &[f64; 6]) -> bool {
+    matrix.iter().all(|v| v.is_finite() && v.abs() <= 1.0e9)
+}
+
 pub fn parse_tysh(data: &[u8]) -> Option<TySh> {
     let f = |at: usize| data.get(at..at + 8).map(|b| f64::from_be_bytes(b.try_into().unwrap_or([0; 8])));
     let mut m = [0.0; 6];
     for (i, v) in m.iter_mut().enumerate() {
         *v = f(2 + i * 8)?;
     }
+    // A plausible type matrix must not create enormous or nonfinite geometry.
+    // The original byte layout is still retained for lossless PSD round-trips.
+    if !usable_tysh_transform(&m) {
+        return None;
+    }
     let (text, used) = VersionedDescriptor::parse_prefix(data.get(52..)?).ok()?;
     let rest = data.get(52 + used..).unwrap_or(&[]);
     let (warp, wused) = match rest.get(2..).and_then(|r| VersionedDescriptor::parse_prefix(r).ok()) {
@@ -381,6 +391,9 @@ pub fn text_layer_from_tysh(data: &[u8], dpi: f32) -> Option<TextLayer> {
         let [a, b, c, d, e, f] = layer.transform.m;
         layer.transform.m = [a, b, c, d, a * x + c * y + e, b * x + d * y + f];
     }
+    if !usable_tysh_transform(&layer.transform.m) {
+        return None;
+    }
     layer.sync_summary();
     Some(layer)
 }
@@ -410,6 +423,9 @@ fn legacy_point_origin(t: &TySh) -> Option<[f64; 2]> {
         return None;
     }
     let shifted = [a * bounds[0] + c * bounds[1] + e, b * bounds[0] + d * bounds[1] + f];
+    if !shifted.iter().all(|v| v.is_finite()) {
+        return None;
+    }
     let origin_distance = e

#4763: refactor(liquify): reuse native brush resizing and anchored HUD feedback

crates/ui-egui/src/brush_resize.rs, crates/ui-egui/src/dialogs/liquify_ui.rs

diff --git a/crates/ui-egui/src/brush_resize.rs b/crates/ui-egui/src/brush_resize.rs
index dfba8e516..297e7fc48 100644
--- a/crates/ui-egui/src/brush_resize.rs
+++ b/crates/ui-egui/src/brush_resize.rs
@@ -170,7 +170,7 @@ fn preview_tint(app: &PhotocraftApp) -> Color32 {
 
 /// A round tip of `radius` screen points: solid out to `hardness` of the radius, fading to clear
 /// at the edge. One mesh (two rings and a centre), so its cost doesn't grow with the brush.
-fn tip_preview(painter: &egui::Painter, c: Pos2, radius: f32, hardness: f32, color: Color32) {
+pub(crate) fn tip_preview(painter: &egui::Painter, c: Pos2, radius: f32, hardness: f32, color: Color32) {
     let clip = painter.clip_rect();
     if !clip.expand(radius).contains(c) {
         return;

#4711: Treat Dissolve as a per-pixel choice

crates/compose/src/effects.rs, crates/compose/src/lib.rs, crates/compose/src/tests.rs, crates/gpu/src/compose.wgsl, crates/gpu/tests/it/parity.rs

diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs
index 5acdd7931..4728e8d46 100644
--- a/crates/compose/src/effects.rs
+++ b/crates/compose/src/effects.rs
@@ -2172,7 +2172,8 @@ pub(crate) fn composite_with_effects_prepared(
             let i = ((y - rect.y0) as usize) * rect.width() as usize + (x - rect.x0) as usize;
             let a = before.px[i];
             let b = if x >= big.x0 && x < big.x1 && y >= big.y0 && y < big.y1 { work.px[((y - big.y0) as usize) * w + (x - big.x0) as usize] } else { a };
-            backdrop.px[i] = mix_premul(a, b, op);
+            // Dissolve chooses the finished layer or the backdrop. Mixing by opacity grays it (#3051).
+            backdrop.px[i] = if layer.blend == BlendMode::Dissolve { if super::dissolve_noise(x, y) < op { b } else { a } } else { mix_premul(a, b, op) };
         }
     }
 }
diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs
index 3cf2f1dc1..8062640b6 100644
--- a/crates/compose/src/lib.rs
+++ b/crates/compose/src/lib.rs
@@ -1440,6 +1440,13 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
                 // Only `adjusted` has changed; this pixel's original backdrop is still in place.
                 let b = backdrop.px[i];
                 let a = adjusted.px[i];
+                // Dissolve is a per-pixel choice, not a partial mix (#3050).
+                if layer.blend == BlendMode::Dissolve {
+                    if dissolve_noise(x, y) < k {
+                        backdrop.px[i] = [a[0], a[1], a[2], b[3]];
+                    }
+                    continue;
+                }
                 let blended = blend::blend_rgb(layer.blend, [b[0], b[1], b[2]], [a[0], a[1], a[2]]);
                 backdrop.px[i] = [b[0] + (blended[0] - b[0]) * k, b[1] + (blended[1] - b[1]) * k, b[2] + (blended[2] - b[2]) * k, b[3]];
             }
@@ -1785,6 +1792,14 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
         for (i, p) in base.px.iter_mut().enumerate() {
             let k = layer.opacity * layer.fill_opacity * mask_k(&mv, i);
             let a = adjusted.px[i];
+            if layer.blend == BlendMode::Dissolve {
+                let x = rect.x0 + (i as i32 % rect.width() as i32);
+                let y = rect.y0 + (i as i32 / rect.width() as i32);
+                if dissolve_noise(x, y) < k {
+                    *p = [a[0], a[1], a[2], p[3]];
+                }
+                continue;
+            }
             let bl = blend::blend_rgb(layer.blend, [p[0], p[1], p[2]], [a[0], a[1], a[2]]);
             for c in 0..3 {
                 p[c] += (bl[c] - p[c]) * k;
@@ -1833,14 +1848,23 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
     let Some(content) = render_content(layer, rect, cx) else { return };
     let opacity = layer.opacity * layer.fill_opacity;
     let gamma = text_gamma(layer);
+    let width = rect.width() as usize;
     for (i, p) in base.px.iter_mut().enumerate() {
         let alpha = p[3];
         if alpha <= 0.0 {
             continue;
         }
-        let s = content.px[i];
+        let mut s = content.px[i];
         // Blend as if the base were opaque, then keep the base's alpha.
-        let r = blend::composite_gamma(layer.blend, [p[0], p[1], p[2], 1.0], s, opacity, gamma);
+        // Dissolve needs a binary coverage decision before compositing.
+        let r = if layer.blend == BlendMode::Dissolve {
+           

#4305: Filter > Other > Frequency Separation (#1714)

crates/engine/src/lib.rs, crates/ui-egui/src/filter_dialog.rs, crates/ui-egui/src/menus.rs

diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index d2aecf37d..23c65523a 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -48,6 +48,7 @@ pub mod filters;
 pub mod filters_ext;
 pub mod float_cmds;
 mod frame_cmds;
+pub mod frequency_separation_cmds;
 pub mod fx_view_cmds;
 pub mod fx_visibility_cmds;
 pub mod gallery_cmds;
diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs
index fa7cb9524..3e3d1eeb0 100644
--- a/crates/ui-egui/src/filter_dialog.rs
+++ b/crates/ui-egui/src/filter_dialog.rs
@@ -300,6 +300,7 @@ pub fn has_dialog(command: &str) -> bool {
                 | "image.mode.colorTable"
                 | "edit.definePattern"
                 | "select.convertToShape"
+                | "layer.frequencySeparation"
         ))
         && photocraft_engine::commands::find(command).is_some_and(|c| !parse_spec(c.params).is_empty())
 }
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index 7f47e6ea6..8b24728ed 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -980,6 +980,7 @@ const PLACE_AFTER: &[(&str, &str)] = &[
     ("filter.render.relight", "filter.render.lightingEffects"),
     ("view.resetView", "view.flipHorizontal"),
     ("filter.other.colorToAlpha", "filter.other.offset"),
+    ("layer.frequencySeparation", "filter.other.colorToAlpha"),
 ];
 
 pub fn menu_items(app: &PhotocraftApp) -> Vec<MenuItem> {
@@ -1747,6 +1748,22 @@ mod tests {
         assert!(!others.iter().any(|p| p.as_str() == "Other"), "no duplicate Other submenu: {others:?}");
     }
 
+    /// Frequency Separation (#1714) is no Photoshop menu item either (it is a recorded action
+    /// there): it follows Color to Alpha in Filter › Other and asks for its radius.
+    #[test]
+    fn frequency_separation_sits_in_filter_other_after_color_to_alpha() {
+        let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
+        app.run("file.new", json!({"width": 32, "height": 32})).unwrap();
+        let items = menu_items(&app);
+        let at = |id: &str| items.iter().position(|i| i.id == id).unwrap_or_else(|| panic!("{id} missing"));
+        let (c2a, fs) = (at("filter.other.colorToAlpha"), at("layer.frequencySeparation"));
+        assert_eq!(fs, c2a + 1);
+        assert_eq!(items[fs].path, items[at("filter.other.highPass")].path);
+        assert_eq!(items[fs].label, "Frequency Separation…");
+        assert!(items[fs].enabled, "the Background is a pixel layer with pixels");
+        assert!(crate::filter_dialog::has_dialog("layer.frequencySeparation"));
+    }
+
     #[test]
     fn menu_bar_labels_have_horizontal_padding_and_open_menus() {
         use egui_kittest::{Harness, kittest::Queryable};

#4042: feat(canvas): rotate view with Shift + middle-mouse drag

crates/ui-egui/src/canvas.rs, crates/ui-egui/src/rotate_view.rs

diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 676d06807..f8e430346 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -3270,7 +3270,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
     // (Space) and a middle-button drag still pan, and a secondary window's Hand never stops, as the
     // box is only drawn and driven on the primary canvas.
     let transform_owns_pointer = primary && app.ui.transform.is_some() && temporary.is_none() && !middle;
-    let hand_pan = tool == Tool::Hand && !transform_owns_pointer;
+    let middle_rotate = middle && ui.input(|i| i.modifiers.shift);
+    let hand_pan = tool == Tool::Hand && !transform_owns_pointer && !middle_rotate;
     // Preferences › Tools › Enable Flick Panning: a Hand drag let go while moving glides on.
     let flick_id = egui::Id::new(("pc-flick-pan", idx, primary, ctx.viewport_id()));
     let flick_on = app.session.prefs().tools.enable_flick_panning;
@@ -3285,7 +3286,14 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
             ctx.data_mut(|m| m.get_temp_mut_or_default::<crate::flick_pan::Flick>(flick_id).stop());
         }
     }
-    if hand_pan && response.dragged() && !stroke_dismissal {
+    if middle_rotate && response.dragged() && !stroke_dismissal {
+        if let Some(pointer) = response.interact_pointer_pos()
+            && crate::rotate_view::middle_drag(&mut view, &xf, pointer, response.drag_delta().x)
+        {
+            ctx.request_repaint();
+        }
+        ctx.set_cursor_icon(crate::rotate_view::cursor(true));
+    } else if hand_pan && response.dragged() && !stroke_dismissal {
         let d = xf.unmap_vec(response.drag_delta()) / (view.zoom / ppp);
         view.center[0] -= d.x;
         view.center[1] -= d.y;
@@ -3310,7 +3318,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
             let d = xf.to_doc(p);
             crate::layer_picker::sample_at(app, d[0], d[1]);
         }
-    } else if primary && !stroke_dismissal {
+    } else if primary && !stroke_dismissal && !middle_rotate {
         let mods = ui.input(|i| i.modifiers);
         // An ⌥-click the window manager took never arrives; say so (alt_grab.rs).
         crate::alt_grab::watch(app, &ctx, egui::Id::new(("pc-alt-grab", idx)), rect, tool, !under_dialog);
diff --git a/crates/ui-egui/src/rotate_view.rs b/crates/ui-egui/src/rotate_view.rs
index 5e173da5d..adb14dde2 100644
--- a/crates/ui-egui/src/rotate_view.rs
+++ b/crates/ui-egui/src/rotate_view.rs
@@ -155,6 +155,31 @@ pub fn trackpad_delta(ctx: &egui::Context, enabled: bool) -> Option<f32> {
     apply_trackpad(0.0, true, rad).filter(|_| rad != 0.0)
 }
 
+/// Shift + middle-drag rotates the view instead of panning. Keep the document point
+/// under the pointer in place, including when the view is flipped or stretched.
+pub fn middle_drag(view: &mut View, xf: &ViewXform, pointer: Pos2, delta_x: f32) -> bool {
+    if !pointer.x.is_finite() || !pointer.y.is_finite() || !delta_x.is_finite() || delta_x == 0.0 {
+        return false;
+    }
+    let current = ViewXform { center: view.center, rotation: view.rotation, ..*xf };
+    let before = current.to_doc(pointer);
+    let rotation = wrap_deg(view.rotation + delta_x * 0.35);
+    if rotation == view.rotation {
+        return false;
+    }
+    let after = ViewXform { rotation, ..current }.to_doc(pointer);
+    

#4035: fix(cmyk): Flatten Image and merges keep the inks of a tagged CMYK document (#3778)

crates/compose/src/lib.rs, crates/engine/src/pixels.rs, crates/io/src/lib.rs, crates/io/tests/it/color.rs

diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs
index 37c6167e9..8b3184688 100644
--- a/crates/compose/src/lib.rs
+++ b/crates/compose/src/lib.rs
@@ -232,10 +232,23 @@ pub fn band_rows_for(width: u32, requested: i32) -> i32 {
 /// The document's composite as a pixel surface in `fmt` (straight RGBA converted to its model
 /// and depth), optionally flattened over an opaque `background`; rendered and written in bands,
 /// so no full-size float composite is held. Tiles equal to the default pixel are pruned.
+///
+/// A CMYK result is separated through the document's own CMYK profile, the one its layers were
+/// read with. A CMYK document whose composite is one plain CMYK pixel layer (Normal, 100 %, no
+/// mask or effects) keeps that layer's inks exactly, as in Photoshop: separating the composite
+/// again would regenerate black and clip inks outside the display gamut (#3778).
 pub fn flatten_to_surface(doc: &Document, fmt: photocraft_color::PixelFormat, background: Option<[f32; 3]>) -> Surface {
     let mut s = Surface::new(fmt);
     let n = fmt.channels();
     let mut data = Vec::new();
+    if let Some((src, need_opaque)) = plain_cmyk_layer(doc, fmt, background) {
+        if copy_inks(src, doc.bounds(), &mut s, need_opaque) {
+            s.prune();
+            return s;
+        }
+        s = Surface::new(fmt);
+    }
+    let cmyk = cmyk_space(doc);
     let _ = render_bands(doc, doc.bounds(), 0, |band| -> Result<(), ()> {
         let band = match background {
             Some(bg) => band.over_background(bg),
@@ -243,9 +256,11 @@ pub fn flatten_to_surface(doc: &Document, fmt: photocraft_color::PixelFormat, ba
         };
         data.clear();
         data.resize(band.px.len() * n, 0.0);
-        for (p, out) in band.px.iter().zip(data.chunks_exact_mut(n)) {
-            photocraft_raster::from_rgba_into(&fmt, *p, out);
-        }
+        photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || {
+            for (p, out) in band.px.iter().zip(data.chunks_exact_mut(n)) {
+                photocraft_raster::from_rgba_into(&fmt, *p, out);
+            }
+        });
         s.write_region(band.rect, &data);
         Ok(())
     });
@@ -253,6 +268,64 @@ pub fn flatten_to_surface(doc: &Document, fmt: photocraft_color::PixelFormat, ba
     s
 }
 
+/// The only visible layer of a CMYK document when the composite is exactly its pixels: a CMYK
+/// pixel layer at `fmt`'s depth, Normal at 100 % opacity and fill, without masks, effects,
+/// Blend If, channel exclusion or advanced blending. The flag says whether its pixels must also
+/// be opaque: when it is flattened over a `background`, or `fmt` has no alpha to carry its
+/// transparency.
+fn plain_cmyk_layer(doc: &Document, fmt: photocraft_color::PixelFormat, background: Option<[f32; 3]>) -> Option<(&Surface, bool)> {
+    use photocraft_color::ColorMode;
+    if doc.mode != ColorMode::Cmyk || fmt.mode != ColorMode::Cmyk {
+        return None;
+    }
+    let mut visible = doc.layers.iter().filter(|l| l.visible);
+    let l = visible.next()?;
+    if visible.next().is_some() {
+        return None;
+    }
+    let LayerContent::Raster(src) = &l.content else { return None };
+    let sf = src.format();
+    let plain = l.blend == BlendMode::Normal
+        && l.opacity >= 1.0
+        && l.fill_opacity >= 1.0
+        && l.mask.is_none()
+        && l.vector_mask.is_none()
+        && (!l.effects.enabled || l.effects.items.is_empty())
+        && l.excl

#4023: i18n(es): add 548 missing rows, fix five filter and video terms

crates/ui-egui/src/i18n/es.tsv

#3967: Add move and delete actions to Smart Filter rows

crates/ui-egui/src/panels.rs, crates/ui-egui/src/smart_ui.rs, docs/control-protocol.md

diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
index 1d3259a71..7f5e4907b 100644
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -2113,6 +2113,7 @@ fn opacity_row_layout(available: f32, label_width: f32, gap: f32) -> (f32, bool)
 }
 
 fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
+    crate::smart_ui::context_menu::close_stale(app, ui.ctx());
     // A new active layer opens its parent groups and is scrolled into view (#152).
     let reveal = crate::layer_reveal::track(app, ui.ctx());
     let Some(st) = app.session.active() else {
@@ -2498,7 +2499,12 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
             }
             continue;
         }
-        let _ = app.run(&id, p);
+        let selection = crate::smart_ui::context_menu::selection_update(app, ui.ctx(), &id, &p);
+        if app.run(&id, p).is_ok()
+            && let Some(selection) = selection
+        {
+            selection.apply(ui.ctx());
+        }
     }
 }
 
diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs
index fd1ed6f01..847a805ec 100644
--- a/crates/ui-egui/src/smart_ui.rs
+++ b/crates/ui-egui/src/smart_ui.rs
@@ -2,6 +2,8 @@
 //! (eye toggles; double-click a filter to re-open its dialog with the recorded parameters; click
 //! to select one; drag onto another smart object to move it there, ⌥-drag to copy it, #4271).
 
+pub(crate) mod context_menu;
+
 use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
 use photocraft_doc::{Document, Layer, LayerContent, LayerId, SmartObject};
 use serde_json::{Value, json};
@@ -135,6 +137,9 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth:
         {
             open_editor(app, ui.ctx(), l.id.0, sm, i);
         }
+        if let Some(i) = index {
+            context_menu::show(app, &resp, l.id, i, actions);
+        }
     }
 }
 
@@ -303,3 +308,9 @@ mod tests {
         assert!(drawn.iter().all(|(_, c)| *c != t.icon));
     }
 }
+
+#[cfg(test)]
+mod context_tests;
+
+#[cfg(test)]
+mod menu_guard_tests;

#3748: control: suggest near matches for unknown command ids and methods (#3674)

crates/automation/src/rpc.rs, crates/engine/src/lib.rs, crates/ui-egui/src/control.rs

diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs
index 507010b9c..62cd46919 100644
--- a/crates/automation/src/rpc.rs
+++ b/crates/automation/src/rpc.rs
@@ -81,6 +81,9 @@ impl Headless {
         match method {
             "engine.execute" => {
                 let id = str_of(&p, "command").ok_or_else(|| bad("engine.execute needs `command`"))?;
+                if METHODS.contains(&id) && photocraft_engine::commands::find(id).is_none() {
+                    return Err(bad(photocraft_engine::command_hints::protocol_method_as_command(id)));
+                }
                 let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(true);
                 self.command_start(id, p.get("params").cloned().unwrap_or(Value::Null), wait)
             }
@@ -172,7 +175,7 @@ impl Headless {
             "doc.close" => self.close(index_of(&p)?),
             "batch" => self.batch(&p),
             "methods" => Ok(json!(METHODS)),
-            other => Err(bad(format!("unknown method `{other}` (try `methods`)"))),
+            other => Err(bad(format!("{} (try `methods`)", photocraft_engine::command_hints::unknown_method(other, METHODS)))),
         }
     }
 
@@ -782,6 +785,23 @@ mod tests {
         assert!(h.handle("jobs.nope", Value::Null).unwrap_err().to_string().contains("unknown method"));
     }
 
+    /// #3674: a guessed name gets a pointer to the right one, in both directions.
+    #[test]
+    fn unknown_names_suggest_the_right_method_or_command() {
+        let mut h = Headless::new();
+        let err = |h: &mut Headless, m: &str, p: Value| h.handle(m, p).unwrap_err().to_string();
+        let e = err(&mut h, "engine.execute", json!({"command": "engine.commands"}));
+        assert!(e.contains("did you mean `command.list`") && e.contains("protocol method"), "{e}");
+        let e = err(&mut h, "engine.execute", json!({"command": "doc.inspect"}));
+        assert!(e.contains("`doc.inspect` is a protocol method"), "{e}");
+        let e = err(&mut h, "engine.execute", json!({"command": "edit.undoo"}));
+        assert!(e.contains("did you mean `edit.undo`"), "{e}");
+        let e = err(&mut h, "doc.inspct", Value::Null);
+        assert!(e.contains("did you mean `doc.inspect`") && e.contains("(try `methods`)"), "{e}");
+        let e = err(&mut h, "edit.undo", Value::Null);
+        assert!(e.contains("engine.execute {\"command\": \"edit.undo\"}"), "{e}");
+    }
+
     #[test]
     fn batch_response_budget_stops_later_steps_even_when_stop_on_error_is_false() {
         let mut h = Headless::new();
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index ff3cde6e4..81d2c2827 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -28,6 +28,7 @@ pub mod channel_cmds;
 mod clip_group;
 pub mod color_cmds;
 pub mod color_to_alpha_cmds;
+pub mod command_hints;
 pub mod commands;
 pub mod comps_cmds;
 pub mod cutout_cmds;
@@ -140,7 +141,8 @@ pub use photocraft_paint::BrushSettings;
 
 #[derive(Debug, thiserror::Error)]
 pub enum EngineError {
-    #[error("unknown command `{0}`")]
+    /// Displays with the nearest registered ids, if any ([`command_hints`], #3674).
+    #[error("unknown command `{id}`{hint}", id = .0, hint = command_hints::unknown_command_hint(.0))]
     UnknownCommand(String),
     #[error("command `{0}` is not available right now: {1}")]
     Disabled(String, String),
@@ -645,7 +647,7 @@ impl Session {
 
     /// [`Self::disabled_reason`] for a call with `params`.

#3519: feat(export): optionally trim transparent margins from layer files

crates/engine/src/file_cmds.rs, crates/engine/src/image_cmds.rs, crates/ui-egui/src/view_cmds.rs

diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs
index 7689f3967..f00c06408 100644
--- a/crates/engine/src/file_cmds.rs
+++ b/crates/engine/src/file_cmds.rs
@@ -1227,6 +1227,7 @@ pub fn export_layers_with(s: &Session, p: &Value, mut write: impl FnMut(&str, &[
     let cmd = "file.export.layersToFiles";
     let format = p.get("format").and_then(Value::as_str).unwrap_or("png").trim_start_matches('.').to_ascii_lowercase();
     let visible_only = p.get("visibleOnly").and_then(Value::as_bool).unwrap_or(true);
+    let trim = p.get("trim").and_then(Value::as_bool).unwrap_or(false);
     let save = SaveOpts::from_params(s, p, cmd)?;
     let d = s.active().ok_or(EngineError::NoDocument)?;
     let prefix = p.get("prefix").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| stem(&d.doc.name));
@@ -1243,6 +1244,32 @@ pub fn export_layers_with(s: &Session, p: &Value, mut write: impl FnMut(&str, &[
         only.visible = true;
         only.clipped = false;
         one.layers = vec![only];
+        if trim {
+            // Measure the isolated layer's rendered alpha, including masks and effects.
+            // Track the extrema without constructing a rectangle for every pixel.
+            let rendered = photocraft_compose::flatten(&one);
+            let width = one.size.width as usize;
+            let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0, 0);
+            if width > 0 {
+                for (pixel, rgba) in rendered.px.iter().enumerate() {
+                    if rgba[3] > 0.0 {
+                        let x = pixel % width;
+                        let y = pixel / width;
+                        x0 = x0.min(x);
+                        y0 = y0.min(y);
+                        x1 = x1.max(x + 1);
+                        y1 = y1.max(y + 1);
+                    }
+                }
+            }
+            // Empty layers retain a valid full-sized canvas.
+            if x0 != usize::MAX {
+                let bounds = Rect::new(x0 as i32, y0 as i32, x1 as i32, y1 as i32);
+                if bounds != one.bounds() {
+                    crate::image_cmds::crop_doc(&mut one, cmd, bounds, true, [0.0; 4])?;
+                }
+            }
+        }
         let name = format!("{}_{:04}_{}.{format}", sanitize(&prefix), i, sanitize(&l.name));
         let (bytes, _warnings) = encode(&one, &name, save)?;
         write(&name, &bytes)?;
@@ -1586,7 +1613,7 @@ pub fn specs() -> Vec<CommandSpec> {
             "Layers to Files…",
             &["File", "Export"],
             None,
-            r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)} → {files}"##,
+            r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"trim":bool=false,"quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)} → {files}"##,
             native_doc,
             layers_to_files
         ),
diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs
index 15a71376b..5bd355f74 100644
--- a/crates/engine/src/image_cmds.rs
+++ b/crates/engine/src/image_cmds.rs
@@ -303,7 +303,7 @@ pub(crate) fn translate_doc(doc: &mut Document, cmd: &str, dx: i32,

#3464: fix(vector): target selected saved paths and own Pen gestures

crates/engine/src/vector_cmds.rs, crates/ui-egui/src/canvas.rs, crates/ui-egui/src/lib.rs, crates/ui-egui/src/path_selection_tests.rs, crates/ui-egui/src/tools/anchor_tools/tests.rs, crates/ui-egui/src/tools/direct_select.rs, crates/ui-egui/src/tools/direct_select/tests.rs, crates/ui-egui/src/vector_paint_tests.rs, crates/ui-egui/src/vector_ui.rs, crates/vector/src/flatten.rs

diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs
index 14579e595..f2630f930 100644
--- a/crates/engine/src/vector_cmds.rs
+++ b/crates/engine/src/vector_cmds.rs
@@ -841,26 +841,32 @@ fn path_set(s: &mut Session, p: &Value) -> Result<Value> {
     let name = p.get("name").and_then(Value::as_str).map(str::to_string);
     let append = p.get("op").and_then(Value::as_str).map(|o| op_from(o).ok_or_else(|| bad(CMD, format!("unknown op `{o}`")))).transpose()?;
     s.edit(if is_work(name.as_deref()) { "Work Path" } else { "Save Path" }, |doc, _| {
-        let merge = |old: Option<&Path>, new: Path| match (old, append) {
-            (Some(o), Some(op)) => {
-                let mut o = o.clone();
-                o.subpaths.extend(new.subpaths.into_iter().map(|mut sp| {
-                    sp.op = op;
-                    sp
-                }));
-                o
-            }
-            _ => new,
+        let merge = |old: Option<&Path>, new: Path| -> Result<Path> {
+            let merged = match (old, append) {
+                (Some(o), Some(op)) => {
+                    let mut o = o.clone();
+                    o.subpaths.extend(new.subpaths.into_iter().map(|mut sp| {
+                        sp.op = op;
+                        sp
+                    }));
+                    o
+                }
+                _ => new,
+            };
+            // Appending small Pen components must not bypass the complete path's bounds and
+            // tessellation budget. Validate before changing the document or its history.
+            vector::flatten::validate_path(&merged).map_err(|e| bad(CMD, e))?;
+            Ok(merged)
         };
         if is_work(name.as_deref()) {
-            doc.work_path = Some(merge(doc.work_path.as_ref(), path));
+            doc.work_path = Some(merge(doc.work_path.as_ref(), path)?);
         } else {
             let n = name.clone().unwrap_or_default();
             match doc.paths.iter_mut().find(|q| q.name == n) {
                 Some(q) => {
-                    q.path = merge(Some(&q.path), path);
+                    q.path = merge(Some(&q.path), path)?;
                 }
-                None => doc.paths.push(NamedPath { name: n, path, psd_raw: None }),
+                None => doc.paths.push(NamedPath { name: n, path: merge(None, path)?, psd_raw: None }),
             }
         }
         Ok(())
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index db0efe31f..46b78b873 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -166,6 +166,62 @@ pub struct Drag {
     /// every Move-tool drag with a selection does (`move_ui::moves_selected_pixels_with`).
     pub sel_move: Option<bool>,
     pub lasso: Option<crate::lasso_ui::Lasso>,
+    /// Freeform Pen and Path Selection gestures belong to their source document and target.
+    vector_owner: Option<VectorGestureOwner>,
+    pen_owner: Option<crate::vector_ui::PenOwner>,
+}
+
+/// The document, revision and target a vector drag started on.
+#[derive(Clone, Debug)]
+struct VectorGestureOwner {
+    doc: photocraft_doc::DocId,
+    document: std::sync::Weak<Document>,
+    revision: u64,
+    layer: Option<photocraft_doc::LayerId>,
+    selected_path: Option<String>,
+    vector_mask: bool,
+    mode: String,
+    tool: Tool,
+}
+
+impl VectorGestureOwner {
+    fn capture(app: &PhotocraftApp) -> Option<Self> {
+        let st = app.session.active()?;
+     

#3461: Replay recorded layer visibility on the intended named layer

crates/engine/src/actions_cmds.rs, crates/engine/src/jobs.rs, crates/engine/src/lib.rs, crates/ui-egui/src/actions.rs, docs/control-protocol.md

diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs
index 348011f75..4ae0a79e0 100644
--- a/crates/engine/src/actions_cmds.rs
+++ b/crates/engine/src/actions_cmds.rs
@@ -77,6 +77,47 @@ pub struct ActionState {
     pub rev: u64,
 }
 
+/// Visibility gestures name a layer for replay, since document-local ids change on another image.
+pub(crate) fn recorded_params(s: &Session, id: &str, mut p: Value) -> Value {
+    if s.actions.recording.is_none() || id != "layer.setProps" || p.get("visible").is_none() {
+        return p;
+    }
+    let Some(st) = s.active() else { return p };
+    let Some(layer) = p.get("layer").and_then(Value::as_u64).map(photocraft_doc::LayerId).or(st.active_layer).and_then(|id| st.doc.layer(id)) else { return p };
+    let ordinal = st.doc.walk().into_iter().filter(|(_, _, l)| l.name == layer.name).position(|(_, _, l)| l.id == layer.id).unwrap_or(0);
+    if let Some(object) = p.as_object_mut() {
+        object.remove("layer");
+        object.remove("coalesce");
+        object.insert("_actionLayer".into(), json!({"name": layer.name, "ordinal": ordinal}));
+    }
+    p
+}
+
+/// Resolve the recorded visibility target identically in engine and shell playback.
+pub fn playback_params(s: &Session, id: &str, p: &Value) -> Result<Value> {
+    let mut p = p.clone();
+    if id == "layer.setProps"
+        && let Some(target) = p.get("_actionLayer")
+    {
+        let name = target.get("name").and_then(Value::as_str).ok_or_else(|| bad(id, "missing recorded layer name"))?;
+        let ordinal =
+            target.get("ordinal").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad(id, "invalid recorded layer ordinal"))?;
+        let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
+        let layer = doc
+            .walk()
+            .into_iter()
+            .filter(|(_, _, l)| l.name == name)
+            .nth(ordinal)
+            .map(|(_, _, l)| l.id)
+            .ok_or_else(|| bad(id, format!("recorded layer `{name}` does not exist")))?;
+        if let Some(object) = p.as_object_mut() {
+            object.remove("_actionLayer");
+            object.insert("layer".into(), json!(layer.0));
+        }
+    }
+    Ok(p)
+}
+
 impl ActionState {
     pub(crate) fn touch(&mut self) {
         self.rev = self.rev.saturating_add(1);
@@ -292,7 +333,7 @@ fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option<V
             failed = Some(json!({"step": i, "id": id, "error": e.to_string()}));
             break;
         }
-        match s.execute(id, params.clone()) {
+        match playback_params(s, id, params).and_then(|p| s.execute(id, p)) {
             Ok(value) => {
                 if let Some(error) = nested_failure(id, &value) {
                     failed = Some(json!({"step": i, "id": id, "error": error}));
@@ -673,6 +714,27 @@ mod tests {
         assert_eq!(s.actions.list[0].disabled.iter().copied().collect::<Vec<_>>(), [1], "rejected toggles change nothing");
     }
 
+    #[test]
+    fn recorded_eye_visibility_replays_on_another_document_and_duplicate_names() {
+        let mut s = Session::new();
+        s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
+        s.execute("layer.new.layer", json!({"name": "Same"})).unwrap();
+        let hidden = s.execute("layer.new.layer", json!({"name": "Same"})).unwrap()["layer"].as_u64().unwrap();
+        s.execute("actions.record", json!({"name": "Hid

#3455: Undo and redo action definition edits from the Actions panel

crates/engine/src/actions_cmds.rs, crates/ui-egui/src/actions.rs, crates/ui-egui/src/menus.rs, docs/control-protocol.md

diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs
index 348011f75..e8a3be636 100644
--- a/crates/engine/src/actions_cmds.rs
+++ b/crates/engine/src/actions_cmds.rs
@@ -75,6 +75,84 @@ pub struct ActionState {
     /// Bumped whenever `list` changes, so the preset store can skip unchanged writes.
     #[serde(skip)]
     pub rev: u64,
+    #[serde(skip)]
+    pub(crate) undo: Vec<ActionEdit>,
+    #[serde(skip)]
+    pub(crate) redo: Vec<ActionEdit>,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub(crate) struct ActionEdit {
+    list: Vec<Action>,
+    recording: Option<(usize, usize)>,
+    shortcuts: std::collections::BTreeMap<String, String>,
+}
+
+fn snapshot(s: &Session) -> ActionEdit {
+    let mut list = s.actions.list.clone();
+    if let Some((_, idx)) = s.actions.recording {
+        let pending: Vec<_> = pending_steps(s, idx).cloned().collect();
+        if let Some(action) = list.get_mut(idx) {
+            action.steps.extend(pending);
+        }
+    }
+    ActionEdit {
+        list,
+        recording: s.actions.recording.map(|(_, idx)| (s.journal.len(), idx)),
+        shortcuts: s.prefs().shortcuts.iter().filter(|(key, _)| key.starts_with(PLAY_SHORTCUT_PREFIX)).map(|(k, v)| (k.clone(), v.clone())).collect(),
+    }
+}
+
+fn edit_action(s: &mut Session, p: &Value, run: fn(&mut Session, &Value) -> Result<Value>, include_pending: bool) -> Result<Value> {
+    let mut before = snapshot(s);
+    if !include_pending {
+        before.list = s.actions.list.clone();
+    }
+    let result = run(s, p)?;
+    if before.list != snapshot(s).list {
+        s.actions.undo.push(before);
+        if s.actions.undo.len() > 100 {
+            s.actions.undo.remove(0);
+        }
+        s.actions.redo.clear();
+    }
+    Ok(result)
+}
+
+pub fn can_undo(s: &Session) -> bool {
+    s.actions.playing == 0 && !s.actions.undo.is_empty()
+}
+pub fn can_redo(s: &Session) -> bool {
+    s.actions.playing == 0 && !s.actions.redo.is_empty()
+}
+
+fn undo_edit(s: &mut Session, redo: bool) -> Result<Value> {
+    not_playing(s, if redo { "actions.redo" } else { "actions.undo" })?;
+    let current = snapshot(s);
+    let prior = if redo { s.actions.redo.pop() } else { s.actions.undo.pop() };
+    let Some(prior) = prior else { return Ok(json!(false)) };
+    if redo {
+        s.actions.undo.push(current);
+    } else {
+        s.actions.redo.push(current);
+    }
+    let old_names: std::collections::BTreeSet<_> = s.actions.list.iter().map(|a| a.name.as_str()).collect();
+    let new_names: std::collections::BTreeSet<_> = prior.list.iter().map(|a| a.name.as_str()).collect();
+    let changed_bindings: Vec<_> = old_names.symmetric_difference(&new_names).map(|name| format!("{PLAY_SHORTCUT_PREFIX}{name}")).collect();
+    s.actions.list = prior.list;
+    s.actions.recording = s.actions.recording.and(prior.recording).map(|(_, idx)| (s.journal.len(), idx));
+    if !changed_bindings.is_empty() {
+        s.edit_prefs(|prefs| {
+            for key in changed_bindings {
+                prefs.shortcuts.remove(&key);
+                if let Some(value) = prior.shortcuts.get(&key) {
+                    prefs.shortcuts.insert(key, value.clone());
+                }
+            }
+        });
+    }
+    s.actions.touch();
+    Ok(json!(true))
 }
 
 impl ActionState {
@@ -528,6 +606,26 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
 
 pub fn specs() -> Vec<CommandSpec> {
     vec![
+        CommandSpec {
+  

#3387: feat(codecs): add lossless JPEG XL export for 8/16-bit images

apps/photocraft/src/services.rs, crates/codecs/Cargo.toml, crates/codecs/src/format.rs, crates/codecs/src/lib.rs

diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs
index 20de0d6be..03218e47a 100644
--- a/apps/photocraft/src/services.rs
+++ b/apps/photocraft/src/services.rs
@@ -89,6 +89,7 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
     ("OpenRaster", &["ora"]),
     ("PNG", &["png"]),
     ("JPEG", &["jpg"]),
+    ("JPEG XL", &["jxl"]),
     ("WebP", &["webp"]),
     ("TIFF", &["tif"]),
     ("Targa", &["tga"]),
diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs
index fa2e1e6cb..e7d598475 100644
--- a/crates/codecs/src/format.rs
+++ b/crates/codecs/src/format.rs
@@ -33,6 +33,8 @@ pub enum Format {
     Fits,
     /// DPX (SMPTE 268M Digital Picture Exchange): luma, RGB or RGBA film and video frames.
     Dpx,
+    /// JPEG XL: lossless export-only until the separate importer lands.
+    Jxl,
     /// Kodak Cineon (`.cin`, the 10-bit film-scan format DPX grew from): read-only.
     Cineon,
 }
@@ -80,6 +82,7 @@ pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[
         "AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \
          until a pure-Rust AV1 decoder is viable. Only enabled with the non-default `avif` feature.",
     ),
+    (Format::Jxl, "Lossless JPEG XL export uses the pure-Rust zune-jpegxl encoder; JPEG XL reading is tracked separately and is not yet available."),
     (
         Format::Cineon,
         "Cineon frames are imported for film work; this crate has no Cineon writer, so save an \
@@ -104,7 +107,7 @@ const TIFF_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::
 
 impl Format {
     /// Every format known to the crate (enabled or not).
-    pub const ALL: [Format; 20] = [
+    pub const ALL: [Format; 21] = [
         Format::Png,
         Format::Jpeg,
         Format::Tiff,
@@ -124,6 +127,7 @@ impl Format {
         Format::Heif,
         Format::Fits,
         Format::Dpx,
+        Format::Jxl,
         Format::Cineon,
     ];
 
@@ -158,6 +162,7 @@ impl Format {
             Format::Heif => "HEIF",
             Format::Fits => "FITS",
             Format::Dpx => "DPX",
+            Format::Jxl => "JPEG XL",
             Format::Cineon => "Cineon",
         }
     }
@@ -185,6 +190,7 @@ impl Format {
             Format::Heif => &["heic", "heif", "hif"],
             Format::Fits => &["fits", "fit", "fts"],
             Format::Dpx => &["dpx"],
+            Format::Jxl => &["jxl"],
             Format::Cineon => &["cin"],
         }
     }
@@ -210,6 +216,7 @@ impl Format {
             Format::Heif => "image/heif",
             Format::Fits => "image/fits",
             Format::Dpx => "image/x-dpx",
+            Format::Jxl => "image/jxl",
             Format::Cineon => "image/x-cin",
         }
     }
@@ -266,6 +273,8 @@ pub fn caps(format: Format) -> FormatCaps {
         Format::Fits => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: &[L::Gray, L::Rgb], alpha: false, text: true, ..base },
         // `text`: the DPX header fields only (see `fidelity_warnings_with`).
         Format::Dpx => FormatCaps { depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], text: true, ..base },
+        // GrayA is widened to RGBA at the same depth; the 16-bit alpha header is corrected.
+        Format::Jxl => FormatCaps { read: false, depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], ..base },
         Format::Cineon => FormatCaps { read: true, write: false, depths: &[S::U16], layouts: &[L::G

#3345: Add grouped gradient presets and selectable style icons

crates/ui-egui/src/panels.rs, crates/ui-egui/src/panels/preset_panels.rs, crates/ui-egui/src/prefs_ui.rs, crates/ui-egui/src/tools/gradient_ui.rs, crates/ui-egui/src/tools/gradient_ui/preset_browser.rs, crates/ui-egui/src/tools/gradient_ui/preset_browser_tests.rs, crates/ui-egui/src/tools/gradient_ui/properties_panel.rs, docs/scorecard.md

diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
index 733f923c8..ee50b7fd8 100644
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -1238,25 +1238,16 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
                         if widgets::dropdown(ui, "gradient-mode", &mut classic, &[(false, "Gradient"), (true, "Classic gradient")], 118.0) {
                             app.ui.tool_options.gradient_classic = classic;
                         }
-                        // Live "Gradient" (a Gradient Fill layer) or "Classic gradient" (pixels):
-                        // both paint with the same engine-owned preset (#1651), and either swatch
-                        // click opens the Gradient Editor window, as in Photoshop.
+                        // Classic's ribbon opens the editor; Live is a preview. Both have a
+                        // separate dropdown for choosing a preset without leaving the tool.
                         if crate::gradient_ui::preset_swatch(app, ui) {
                             app.ui.panels.gradient_editor = true;
                         }
                         widgets::vline(ui, 22.0);
                         ui.spacing_mut().item_spacing.x = 2.0;
                         let before = app.ui.tool_options.clone();
-                        for (style, icon, tip) in [
-                            ("linear", "gradient", tl!("Linear Gradient")),
-                            ("radial", "circle", tl!("Radial Gradient")),
-                            ("angle", "rotate-cw", tl!("Angle Gradient")),
-                            ("reflected", "arrow-left-right", tl!("Reflected Gradient")),
-                            ("diamond", "diamond", tl!("Diamond Gradient")),
-                        ] {
-                            if icons::button(ui, icon, 24.0, app.ui.tool_options.gradient_style == style, tip).clicked() {
-                                app.ui.tool_options.gradient_style = style.into();
-                            }
+                        if let Some(style) = crate::gradient_ui::style_buttons(ui, &app.ui.tool_options.gradient_style, app.session.prefs().interface.native_gradient_icons) {
+                            app.ui.tool_options.gradient_style = style.into();
                         }
                         ui.spacing_mut().item_spacing.x = 8.0;
                         widgets::vline(ui, 22.0);
diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs
index db908ae23..d97556f36 100644
--- a/crates/ui-egui/src/prefs_ui.rs
+++ b/crates/ui-egui/src/prefs_ui.rs
@@ -1026,6 +1026,7 @@ fn humanize(key: &str) -> String {
         "appearanceMode" => return "Appearance Mode".into(),
         "darkTheme" => return "Dark Theme".into(),
         "lightTheme" => return "Light Theme".into(),
+        "nativeGradientIcons" => return "Use PhotoCraft-native gradient icons".into(),
         _ => {}
     }
     // These controls appear only for WebP, so reuse the existing translated labels.
@@ -3039,6 +3040,39 @@ mod tests {
         assert!(h.state().custom_titlebar);
     }
 
+    #[test]
+    fn native_gradient_icons_apply_cancel_and_restart() {
+        use egui_kittest::{Harness, kittest::Queryable};
+        let (mut app, store) = app_with_store();
+        app.run("prefs.set", json!({"path": "interface.language", "value": "en"})).unwrap();
+        let id = open_preferences(&mut app, "interface");
+        let mut h =

#3142: Read applied Sony RAW metadata and crop ILCE-6000 images

crates/raw/src/cameras.rs, docs/file-format-parity.md, docs/gaps.md

diff --git a/crates/raw/src/cameras.rs b/crates/raw/src/cameras.rs
index 1c605734f..827ac3946 100644
--- a/crates/raw/src/cameras.rs
+++ b/crates/raw/src/cameras.rs
@@ -95,6 +95,9 @@ const PROFILES: &[Profile] = &[Profile {
 ///   right and bottom), the same corner as the D4.
 /// * **ILCE-7** (2 files): 12 x 12 pixels in from the left and top of the
 ///   6048 x 4024 cRAW data (36 and 12 pixels of margin on the right and bottom).
+/// * **ILCE-6000** (3 files): the same 12 x 12 origin and 6000 x 4000 image;
+///   confirmed by the file's EXIF size and SR2SubIFD's area [12, 12, 6012, 4012],
+///   and checked against the matching camera JPEGs. No colour profile is inferred.
 struct ImageArea {
     make: &'static str,
     model: &'static str,
@@ -107,6 +110,7 @@ struct ImageArea {
 const IMAGE_AREAS: &[ImageArea] = &[
     ImageArea { make: "NIKON CORPORATION", model: "NIKON D800E", size: (7424, 4924), crop: (8, 6, 7360, 4912) },
     ImageArea { make: "SONY", model: "ILCE-7", size: (6048, 4024), crop: (12, 12, 6000, 4000) },
+    ImageArea { make: "SONY", model: "ILCE-6000", size: (6048, 4024), crop: (12, 12, 6000, 4000) },
 ];
 
 /// Applies the measured profile of the camera that made `s`, when there is one,
@@ -114,7 +118,7 @@ const IMAGE_AREAS: &[ImageArea] = &[
 pub(crate) fn apply(s: &mut Sensor) {
     let (Some(make), Some(model)) = (s.make.as_deref(), s.model.as_deref()) else { return };
     let (make, model) = (make.trim(), model.trim());
-    if let Some(a) = IMAGE_AREAS.iter().find(|a| a.make == make && a.model == model && a.size == (s.width, s.height)) {
+    if let Some(a) = IMAGE_AREAS.iter().find(|a| a.make == make && a.model == model && a.size == (s.width, s.height) && s.crop == s.active) {
         let (x, y, w, h) = a.crop;
         s.crop = Rect::new(x, y, w, h);
     }
@@ -196,6 +200,19 @@ mod tests {
         apply(&mut s);
         assert_eq!(s.crop, Rect::new(12, 12, 6000, 4000));
 
+        let mut s = sensor("SONY", "ILCE-6000", 6048, 4024);
+        apply(&mut s);
+        assert_eq!(s.crop, Rect::new(12, 12, 6000, 4000));
+        assert!(s.color.calibrations.is_empty() && s.tone_curve.is_empty());
+        // A file's explicit crop wins, including a different in-camera aspect ratio.
+        s.crop = Rect::new(12, 324, 6000, 3376);
+        apply(&mut s);
+        assert_eq!(s.crop, Rect::new(12, 324, 6000, 3376));
+
+        let mut s = sensor("SONY", "ILCE-6000", 3024, 2012);
+        apply(&mut s);
+        assert_eq!(s.crop, Rect::new(0, 0, 3024, 2012));
+
         // Another size or model of the same maker is left alone.
         let mut s = nef("NIKON D800E", 2496, 1646);
         apply(&mut s);

#3139: Draw right-to-left layer names in visual word order

Cargo.lock, crates/ui-egui/Cargo.toml, crates/ui-egui/src/lib.rs, crates/ui-egui/src/panels/layer_row_ui.rs

diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 131a670ee..22018457a 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -24,6 +24,7 @@ pub mod analysis_ui;
 pub mod anchor_tools;
 pub mod artboard_tool;
 pub mod artboard_ui;
+pub mod bidi_label;
 mod blend_keys;
 pub(crate) mod blend_preview;
 mod brand;

#2300: Scroll the viewport while outlining with Polygonal Lasso

crates/ui-egui/src/canvas.rs

diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 391ad464f..f27bec347 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -2879,6 +2879,49 @@ fn hdr_preview(app: &PhotocraftApp, doc: &photocraft_doc::Document) -> Option<[f
     app.session.color.hdr_preview(doc).map(|h| [h.exposure, h.gamma])
 }
 
+/// Between Polygonal Lasso clicks, reveal more of the image near or beyond the viewport edge.
+/// Only the view moves: fixed vertices stay in document coordinates, including while button-up.
+fn polygon_edge_scroll(app: &PhotocraftApp, ctx: &egui::Context, idx: usize, rect: Rect, view: &mut View, ppp: f32) {
+    if app.ui.polygon.is_empty()
+        || app.ui.tool != Tool::PolygonLasso
+        || app.session.active_index() != Some(idx)
+        || !app.ui.dialogs.is_empty()
+        || app.camera_raw.is_some()
+        || app.discard.is_some()
+        || app.drag.is_some()
+        || ctx.text_edit_focused()
+        || egui::Popup::is_any_open(ctx)
+        || crate::hold_keys::for_frame(app, ctx, false).is_some()
+    {
+        return;
+    }
+    let Some((pointer, dt)) = ctx.input(|i| (i.focused && !i.pointer.middle_down()).then(|| i.pointer.hover_pos().map(|p| (p, i.stable_dt))).flatten()) else {
+        return;
+    };
+    let zoom = view.zoom / ppp;
+    if !pointer.x.is_finite() || !pointer.y.is_finite() || !dt.is_finite() || dt <= 0.0 || !zoom.is_finite() || zoom <= 0.0 {
+        return;
+    }
+    // Moving over a neighbouring document is not a request to pan either tiled view.
+    if arranged_cells(app, app.last_canvas_rect).is_some_and(|cells| cells.iter().any(|(other, cell)| *other != idx && cell.contains(pointer))) {
+        return;
+    }
+    let edge = 32.0_f32.min(rect.width() * 0.25).min(rect.height() * 0.25);
+    if edge <= 0.0 {
+        return;
+    }
+    let speed = |p: f32, lo: f32, hi: f32| {
+        let direction = ((edge - (hi - p)) / edge).clamp(0.0, 1.0) - ((edge - (p - lo)) / edge).clamp(0.0, 1.0);
+        if direction == 0.0 { 0.0 } else { direction.signum() * (80.0 + 520.0 * direction.abs()) }
+    };
+    let velocity = vec2(speed(pointer.x, rect.left(), rect.right()), speed(pointer.y, rect.top(), rect.bottom()));
+    let velocity = velocity * (600.0 / velocity.length().max(600.0));
+    let xf = ViewXform { rect, zoom, center: view.center, flip: app.ui.view.flip_horizontal, rotation: view.rotation, aspect: app.ui.view.display_aspect() };
+    let delta = xf.unmap_vec(velocity) * dt.min(0.05) / zoom;
+    view.center[0] += delta.x;
+    view.center[1] += delta.y;
+}
+
 /// Draw one canvas view and handle its input. `primary` = main window (tools active).
 pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: Rect, mut view: View, primary: bool) -> View {
     let ctx = ui.ctx().clone();
@@ -2922,8 +2965,16 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
     if view.fill_pending && rect.width() > 50.0 {
         fill_view(&mut view, &doc, rect.size(), ppp, aspect);
     }
+    let previous_center = view.center;
+    if primary {
+        polygon_cancel_stale(app);
+        polygon_edge_scroll(app, &ctx, idx, rect, &mut view, ppp);
+    }
     // Preferences › Tools › Overscroll off: clamp before anything is drawn (scrollbars.rs).
-    if !app.session.prefs().tools.overscroll && crate::scrollbars::clamp_view_with_aspect(&mut view, rect.size(), ppp, aspect) {
+    if !app.sess

#1991: feat(actions): import and export reusable Actions JSON files

crates/automation/src/workspace.rs, crates/engine/src/actions_cmds.rs, crates/ui-egui/src/actions.rs

diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs
index f73a70238..90e979ab0 100644
--- a/crates/automation/src/workspace.rs
+++ b/crates/automation/src/workspace.rs
@@ -368,7 +368,7 @@ fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
             params.get("data").is_none() && params.get("path").is_some()
         }
         // With a `path` the file is written there; without one the bytes come back as `data`.
-        "actions.save" | "swatches.export" => params.get("path").is_some(),
+        "actions.save" | "actions.export" | "swatches.export" => params.get("path").is_some(),
         // Shortcut sets: a .kys read or written by path, or a Summarize page written by path.
         "edit.keyboardShortcuts" => {
             params.get("path").is_some() || ["importKys", "exportKys", "summarize"].iter().any(|k| params.get(*k).and_then(|v| v.get("path")).is_some())
@@ -778,6 +778,7 @@ mod tests {
             ("swatches.export", serde_json::json!({"path": "/outside/set.ase"})),
             ("actions.load", serde_json::json!({"path": "/outside/set.atn"})),
             ("actions.save", serde_json::json!({"path": "/outside/set.atn"})),
+            ("actions.export", serde_json::json!({"path": "/outside/actions.json"})),
         ] {
             assert!(authorize_engine_command(id, &params).is_err(), "{id}: {params}");
         }
@@ -790,6 +791,8 @@ mod tests {
             ("swatches.export", serde_json::json!({"format": "ase"})),
             ("actions.load", serde_json::json!({"data": "QUJD"})),
             ("actions.save", serde_json::json!({"set": "S"})),
+            ("actions.import", serde_json::json!({"data": "{\"version\":1,\"actions\":[]}"})),
+            ("actions.export", serde_json::json!({})),
         ] {
             assert!(authorize_engine_command(id, &params).is_ok(), "{id}: {params}");
         }
diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs
index d881a6112..a074ac747 100644
--- a/crates/engine/src/actions_cmds.rs
+++ b/crates/engine/src/actions_cmds.rs
@@ -22,6 +22,8 @@ use serde_json::{Value, json};
 use crate::commands::CommandSpec;
 use crate::{EngineError, Result, Session};
 
+mod json_file;
+
 fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
     EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
 }
@@ -580,6 +582,26 @@ fn set_color(s: &mut Session, p: &Value) -> Result<Value> {
 
 pub fn specs() -> Vec<CommandSpec> {
     vec![
+        CommandSpec {
+            id: "actions.import",
+            label: "Load Actions…",
+            menu: &[],
+            shortcut: None,
+            params: r##"{"data":str} → {imported, skipped, actions:[names]}. Append version 1 Actions JSON, retarget nested calls, and rename conflicts; unchanged reimports do nothing."##,
+            enabled: always,
+            run: json_file::import,
+            journal: false,
+        },
+        CommandSpec {
+            id: "actions.export",
+            label: "Save Actions…",
+            menu: &[],
+            shortcut: None,
+            params: r##"{"path":str?} → {count, bytes, data?:JSON string, path?}. Export version 1 Actions JSON, preserving disabled steps, dialog toggles and sets."##,
+            enabled: always,
+            run: json_file::export,
+            journal: false,
+        },
         CommandSpec {
             id: "actions.move",
             label: "Move Action or Step",
diff --git a/crates/ui

Checkpoint validation

  • All 41 integrated selected heads verified as ancestors.
  • cargo fmt --all -- --check: PASS on the partial integration tree.
  • Full workspace, corpus, GPU, wasm, CI and manual tests: NOT RUN; integration remains incomplete.
  • Default Cargo cache was not writable; a workspace-local cache check is in progress. No credentials, global settings, dependency pins, tests or thresholds were changed.