diff --git a/crates/ui-egui/src/brush_panel.rs b/crates/ui-egui/src/brush_panel.rs index 332c80c4b7..be1ef1391a 100644 --- a/crates/ui-egui/src/brush_panel.rs +++ b/crates/ui-egui/src/brush_panel.rs @@ -368,9 +368,11 @@ fn settings_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // The picked preset is recorded by name (`brush.presets.setCurrent`, identity, so // look-alike duplicates stay distinct), since the engine can't infer it from the // brush afterwards. - ui.add_enabled_ui(on, |ui| section_body(ui, &mut b, section, &app.session.tools.presets, app.session.tools.current_preset.as_deref())) - .inner - .inspect(|name| run_or_status(app, "brush.presets.setCurrent", json!({ "name": name }))); + ui.add_enabled_ui(on, |ui| { + section_body(ui, &mut b, section, &app.session.tools.presets, app.session.tools.current_preset.as_deref(), app.ui.tool) + }) + .inner + .inspect(|name| run_or_status(app, "brush.presets.setCurrent", json!({ "name": name }))); }); }); }); diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index f38a1b9f0c..8829510851 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -620,6 +620,65 @@ fn harness(tab: usize, section: usize) -> Harness<'static, PhotocraftApp> { h } +#[test] +fn transfer_mixer_edits_use_the_brush_command_and_survive_real_tool_swaps() { + use crate::state::Tool; + use egui_kittest::kittest::{NodeT, Queryable}; + + let mut h = harness(0, 6); + let a = h.state_mut(); + a.ui.tool = Tool::Brush; + a.session.tools.brush.transfer.enabled = true; + a.session.tools.brush.transfer.wetness = + paint::Dynamic { jitter: 0.41, minimum: 0.43, control: Control::PenPressure, fade_steps: 339, ..Default::default() }; + a.session.tools.brush.transfer.mix = paint::Dynamic { jitter: 0.63, minimum: 0.67, control: Control::PenPressure, fade_steps: 449, ..Default::default() }; + crate::paint_mouse::sync_tool_brush(a); + a.ui.tool = Tool::MixerBrush; + crate::paint_mouse::sync_tool_brush(a); + h.run_steps(2); + h.hover_at(h.get_by_label("Opacity Jitter").rect().center()); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -250.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(20); + let journal = h.state().session.journal.len(); + let field = h.query_all_by_role(egui::accesskit::Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(41.0)).unwrap(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + assert!((h.state().session.tools.brush.transfer.wetness.jitter - 0.42).abs() < 1e-6); + assert_eq!(h.state().session.journal.len(), journal + 1); + let (id, params) = last_journal(h.state()).unwrap(); + assert_eq!(id, "tools.setBrush"); + assert_eq!(params["brush"], json!({"transfer": {"wetness": {"jitter": 0.42_f32}}})); + let mixer = h.state().session.tools.brush.clone(); + + let a = h.state_mut(); + a.ui.tool = Tool::Brush; + crate::paint_mouse::sync_tool_brush(a); + h.run_steps(2); + let brush = h.state().session.tools.brush.clone(); + let field = h.query_all_by_role(egui::accesskit::Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(41.0)).unwrap(); + assert!(field.accesskit_node().is_disabled()); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + assert_eq!(h.state().session.tools.brush, brush); + assert_eq!(h.state().session.journal.len(), journal + 1, "inactive edits and tool swaps add no brush command"); + let a = h.state_mut(); + a.ui.tool = Tool::MixerBrush; + crate::paint_mouse::sync_tool_brush(a); + h.run_steps(2); + assert_eq!(h.state().session.tools.brush, mixer, "the Mixer Brush keeps its own edited settings"); + assert!( + h.query_all_by_role(egui::accesskit::Role::SpinButton) + .any(|node| node.accesskit_node().numeric_value() == Some(42.0) && !node.accesskit_node().is_disabled()) + ); +} + fn toggle_value(app: &PhotocraftApp, section: usize) -> bool { let brush = serde_json::to_value(&app.session.tools.brush).unwrap(); let path = match section { diff --git a/crates/ui-egui/src/brush_sections.rs b/crates/ui-egui/src/brush_sections.rs index 9e66f9b0a4..57254143ed 100644 --- a/crates/ui-egui/src/brush_sections.rs +++ b/crates/ui-egui/src/brush_sections.rs @@ -8,6 +8,7 @@ use photocraft_engine::BrushSettings; use photocraft_engine::paint::{self, BrushPreset, Control, Dynamic, MAX_BRUSH_SIZE, MaskMode, Pattern, PatternStyle, TipShape}; use crate::brush_preview; +use crate::state::Tool; use crate::theme::{self, Tokens}; use crate::widgets; @@ -542,12 +543,16 @@ fn color_dynamics(ui: &mut egui::Ui, b: &mut BrushSettings) { signed_pct(ui, tl!("Purity"), &mut c.purity, -1.0, 1.0); } -fn transfer(ui: &mut egui::Ui, b: &mut BrushSettings) { +fn transfer(ui: &mut egui::Ui, b: &mut BrushSettings, tool: Tool) { let tr = &mut b.transfer; dynamic(ui, "brush-opacity-ctl", tl!("Opacity Jitter"), &mut tr.opacity, 1.0, Some(tl!("Minimum")), false); dynamic(ui, "brush-flow-ctl", tl!("Flow Jitter"), &mut tr.flow, 1.0, Some(tl!("Minimum")), false); - dynamic(ui, "brush-wet-ctl", tl!("Wetness Jitter"), &mut tr.wetness, 1.0, Some(tl!("Minimum")), false); - dynamic(ui, "brush-mix-ctl", tl!("Mix Jitter"), &mut tr.mix, 1.0, Some(tl!("Minimum")), false); + // Wetness and mix only affect the Mixer Brush. Keep their settings visible and saved + // for other tools, but disable the whole blocks, including controllers and minimums. + ui.add_enabled_ui(tool == Tool::MixerBrush, |ui| { + dynamic(ui, "brush-wet-ctl", tl!("Wetness Jitter"), &mut tr.wetness, 1.0, Some(tl!("Minimum")), false); + dynamic(ui, "brush-mix-ctl", tl!("Mix Jitter"), &mut tr.mix, 1.0, Some(tl!("Minimum")), false); + }); } /// The Mixer Brush options the brush carries (Photoshop shows them in the Mixer Brush's options @@ -602,7 +607,8 @@ fn smoothing(ui: &mut egui::Ui, b: &mut BrushSettings) { /// Controls for section `i` (see [`crate::brush_panel::SECTIONS`]). `presets` feed the tip grids. /// Returns the name of a preset whose tip was picked in the Brush Tip Shape grid, for /// `brush.presets.setCurrent`. -pub fn section_body(ui: &mut egui::Ui, b: &mut BrushSettings, i: usize, presets: &[BrushPreset], current: Option<&str>) -> Option { +/// The active `tool` determines which Transfer controls apply. +pub fn section_body(ui: &mut egui::Ui, b: &mut BrushSettings, i: usize, presets: &[BrushPreset], current: Option<&str>, tool: Tool) -> Option { let t = Tokens::get(ui.ctx()); let note = |ui: &mut egui::Ui, s: &str| { ui.label(RichText::new(s).color(t.text_faint).font(theme::medium(11.5))); @@ -617,7 +623,7 @@ pub fn section_body(ui: &mut egui::Ui, b: &mut BrushSettings, i: usize, presets: 4 => dual_brush(ui, b, presets), 5 => color_dynamics(ui, b), 6 => { - transfer(ui, b); + transfer(ui, b, tool); mixer_options(ui, b); } 7 => pose(ui, b), @@ -663,7 +669,7 @@ mod tests { ready = true; return; } - section_body(ui, b, section, &[], None); + section_body(ui, b, section, &[], None, Tool::MixerBrush); }, b, ); @@ -694,6 +700,134 @@ mod tests { } } + struct TransferState { + brush: BrushSettings, + tool: Tool, + } + + fn transfer_mixer_brush() -> BrushSettings { + let mut b = BrushSettings::default(); + b.transfer.enabled = true; + for (dynamic, jitter, minimum, fade_steps) in [ + (&mut b.transfer.opacity, 0.13, 0.17, 119), + (&mut b.transfer.flow, 0.27, 0.29, 229), + (&mut b.transfer.wetness, 0.41, 0.43, 339), + (&mut b.transfer.mix, 0.63, 0.67, 449), + ] { + *dynamic = Dynamic { control: Control::Fade, jitter, minimum, fade_steps, ..Default::default() }; + } + b + } + + fn transfer_mixer_harness(kind: theme::ThemeKind, tool: Tool) -> Harness<'static, TransferState> { + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(340.0, 1400.0)).build_ui_state( + move |ui, state: &mut TransferState| { + if !ready { + crate::PhotocraftApp::setup_context(ui.ctx(), kind); + ready = true; + return; + } + ui.add_enabled_ui(state.brush.transfer.enabled, |ui| section_body(ui, &mut state.brush, 6, &[], None, state.tool)); + }, + TransferState { brush: transfer_mixer_brush(), tool }, + ); + h.run_steps(3); + h + } + + /// #3825: Mixer-only blocks follow the tool, including sliders, minimums and Fade steps. + #[test] + fn transfer_mixer_controls_follow_the_tool_and_section_in_every_theme() { + for kind in theme::ThemeKind::ALL { + let mut h = transfer_mixer_harness(kind, Tool::Brush); + let before = h.state().brush.clone(); + for tool in Tool::ALL { + h.state_mut().tool = tool; + h.run_steps(2); + for (mixer_only, values) in [(false, [13.0, 17.0, 119.0, 27.0, 29.0, 229.0]), (true, [41.0, 43.0, 339.0, 63.0, 67.0, 449.0])] { + for value in values { + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(value)).unwrap(); + assert_eq!(field.accesskit_node().is_disabled(), mixer_only && tool != Tool::MixerBrush, "{kind:?}, {tool:?}, {value}"); + } + } + for (index, node) in h.query_all_by_role(Role::ComboBox).enumerate() { + assert_eq!(node.accesskit_node().is_disabled(), index >= 2 && tool != Tool::MixerBrush, "{kind:?}, {tool:?}: controller {index}"); + } + assert_eq!(h.state().brush, before, "changing tool context only changes availability"); + } + h.state_mut().tool = Tool::MixerBrush; + h.state_mut().brush.transfer.enabled = false; + h.run_steps(2); + assert!(h.query_all_by_role(Role::SpinButton).all(|node| node.accesskit_node().is_disabled()), "section Off still disables every field"); + assert!(h.query_all_by_role(Role::ComboBox).all(|node| node.accesskit_node().is_disabled())); + h.state_mut().brush.transfer.enabled = true; + h.state_mut().brush.transfer.wetness.control = Control::Off; + h.run_steps(2); + let minimum = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(43.0)).unwrap(); + assert!(minimum.accesskit_node().is_disabled(), "Control Off still disables the applicable minimum"); + } + } + + #[test] + fn transfer_mixer_controls_ignore_disabled_edits_and_keep_values_on_reactivation() { + let mut h = transfer_mixer_harness(theme::ThemeKind::ProMedium, Tool::Brush); + let before = h.state().brush.clone(); + for value in [41.0, 43.0, 339.0, 63.0, 67.0, 449.0] { + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(value)).unwrap(); + let at = field.rect().center(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.event(egui::Event::Text("999".into())); + h.key_press(egui::Key::Enter); + h.run_steps(2); + h.hover_at(at); + h.drag_at(at); + h.run_steps(1); + h.drop_at(at + vec2(60.0, 0.0)); + h.run_steps(2); + assert_eq!(h.state().brush, before, "inactive field {value} ignores keyboard and pointer input"); + } + for label in ["Wetness Jitter", "Mix Jitter"] { + let row = h.get_by_label(label).rect(); + let from = pos2(row.left() + 120.0, row.bottom() + 16.0); + h.hover_at(from); + h.drag_at(from); + h.run_steps(1); + h.hover_at(from + vec2(150.0, 0.0)); + h.run_steps(1); + h.drop_at(from + vec2(150.0, 0.0)); + h.run_steps(2); + assert_eq!(h.state().brush, before, "the inactive {label} track ignores a drag"); + } + h.state_mut().tool = Tool::MixerBrush; + h.run_steps(2); + let row = h.get_by_label("Wetness Jitter").rect(); + let from = pos2(row.left() + 120.0, row.bottom() + 16.0); + h.hover_at(from); + h.drag_at(from); + h.run_steps(1); + h.hover_at(from + vec2(150.0, 0.0)); + h.run_steps(1); + h.drop_at(from + vec2(150.0, 0.0)); + h.run_steps(2); + assert!(h.state().brush.transfer.wetness.jitter > 0.6, "the same track accepts a drag when applicable"); + h.state_mut().brush.transfer.wetness.jitter = 0.41; + h.run_steps(2); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(41.0)).unwrap(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + assert!((h.state().brush.transfer.wetness.jitter - 0.42).abs() < 1e-6); + let after = h.state().brush.clone(); + h.state_mut().tool = Tool::Brush; + h.run_steps(2); + h.state_mut().tool = Tool::MixerBrush; + h.run_steps(2); + assert_eq!(h.state().brush, after); + assert!(h.query_all_by_role(Role::SpinButton).any(|node| node.accesskit_node().numeric_value() == Some(42.0) && !node.accesskit_node().is_disabled())); + } + fn set_controls(b: &mut BrushSettings, control: Control) { for d in [ &mut b.shape_dynamics.size, diff --git a/docs/brush-parity.md b/docs/brush-parity.md index c482bb5c53..263901c9ad 100644 --- a/docs/brush-parity.md +++ b/docs/brush-parity.md @@ -1,6 +1,6 @@ # Brush and painting parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (dragged preset tip preview; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view; Color Replacement is a live toolbar tool; no brush-feel estimate changed; 2026-10-11: Pattern Stamp and Background Eraser draw live, TOOL-213-7) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Transfer Wetness/Mix tool applicability; dragged preset tip preview; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view; Color Replacement is a live toolbar tool; no brush-feel estimate changed; 2026-10-11: Pattern Stamp and Background Eraser draw live, TOOL-213-7) · **Target:** Adobe Photoshop 2026 Painting is where Photoshop users notice differences first, so it gets its own checklist: the brush engine (`crates/paint/src/brush.rs`), the Brush Settings UI @@ -26,6 +26,7 @@ tablets against Photoshop side by side. | Color Dynamics (FG/BG, hue/sat/brightness jitter, purity, per tip) | done | | | Hue Jitter uses a standard percentage-amount track instead of a hue spectrum | done | #3825 (Hue Jitter appearance only); rendered track checked in all 14 themes; numeric and pointer edits retain percentage units and engine command routing. | | Transfer (opacity/flow/wetness/mix jitter and controls) | done | | +| Transfer Wetness/Mix blocks disabled outside Mixer Brush, retaining their settings | done | #3825 (tool applicability only); UI regressions cover all 14 themes and 67 tools, disabled input, section/Control Off, command routing and per-tool settings | | Shape Dynamics and Transfer minimum fields disabled when their Control is Off, retaining their values | done | #3825 (minimum-field interaction only); `brush_sections.rs` UI regressions cover all six fields, reactivation and disabled input | | Brush Pose (tilt, rotation, pressure overrides) | done | | | Noise, Wet Edges, Build-up, Protect Texture | done | | @@ -124,4 +125,5 @@ tablets against Photoshop side by side. | 2026-10-12 | minor | A shaped tip's cursor outline turns and flips with a rotated or flipped view, and follows pixel aspect correction | | 2026-10-13 | minor | Shape Dynamics and Transfer minimum inputs follow their Control and retain their values while disabled (#3825, partial). No brush-feel estimate changed. | | 2026-10-13 | minor | Hue Jitter uses the same themed amount slider as other jitter controls (#3825, partial). No brush-feel estimate changed. | +| 2026-10-13 | minor | Transfer Wetness/Mix controls follow the active tool and retain their settings while disabled (#3825, partial). No brush-feel estimate changed. | | 2026-10-13 | minor | Dragging a brush preset shows its cached tip thumbnail and name in the Brushes panel and preset picker (#4225, partial). No brush-feel estimate changed. | diff --git a/docs/gaps.md b/docs/gaps.md index 9b281d6786..752fdf05c9 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -168,6 +168,9 @@ what an agent can't finish alone. - **Estimate:** 250–400 h (many overlap with other gaps; count each fix once). ### G4. Brush feel +- **Progress (2026-10-13, #3825 partial):** Transfer’s Wetness Jitter and Mix Jitter + blocks are disabled outside the Mixer Brush, retaining their saved settings. Opacity + and Flow keep their existing availability; other layout and brush-feel gaps remain open. - **Progress (2026-10-13, #3825 partial):** Hue Jitter shows a standard themed percentage-amount track rather than a hue spectrum. Other Brush Settings layout and brush-behaviour gaps remain open. @@ -296,6 +299,7 @@ what an agent can't finish alone. | 2026-10-15 | minor | G9: Animated Zoom preference takes effect (hold the Zoom tool to zoom continuously, #6210); prefs that do nothing 25 of 166; no estimate changed | | 2026-10-16 | minor | G7: Content-Aware Move's live preview covers every selection the command takes (over 256 × 256: a coarse result solved scaled down, then the exact one); TOOL-213-4 done (#6680) | | 2026-10-15 | minor | G9: Save in Background off saves in the foreground under a modal progress dialog (#6207); preferences that do nothing 25 → 24 of 169 | +| 2026-10-13 | minor | G4: Transfer Wetness/Mix tool applicability (#3825, partial); no readiness percentage or estimate changed | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 |