From 17abae97413def4f80cc84b7d41f819d6cff970e Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:52:47 +0300 Subject: [PATCH 1/4] Brush Settings: expose section lock names and state Co-authored-by: GPT-6 --- crates/ui-egui/src/brush_panel.rs | 1 + crates/ui-egui/src/brush_panel_tests.rs | 69 +++++++++++++++++++++++++ docs/brush-parity.md | 5 +- docs/gaps.md | 7 ++- 4 files changed, 79 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/brush_panel.rs b/crates/ui-egui/src/brush_panel.rs index 515e6e5a21..7c44749565 100644 --- a/crates/ui-egui/src/brush_panel.rs +++ b/crates/ui-egui/src/brush_panel.rs @@ -324,6 +324,7 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting if let Some(lock) = section_lock(b, i) { let lr = egui::Rect::from_center_size(egui::pos2(r.right() - 11.0, r.center().y), vec2(18.0, 18.0)); let lresp = ui.interact(lr, ui.id().with(("brush-sec-lock", i)), Sense::click()); + lresp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), *lock, format!("{}: {}", tl!("Lock"), tl!(*name)))); // Like Photoshop: lock icons stay visible on every lockable section row. let tint = if *lock { t.text } else { t.text_faint }; icons::paint(ui, lr, if *lock { "lock" } else { "lock-open" }, 12.0, tint); diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 750a9d5506..7ad74fb1d8 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -275,6 +275,75 @@ fn section_locks_are_always_visible_and_toggle_on_click() { assert_eq!(h.state().ui.brush_section, 0); } +#[test] +fn section_lock_buttons_report_their_section_and_state_in_every_theme() { + use egui::accesskit::{Role, Toggled}; + use egui_kittest::kittest::{NodeT, Queryable}; + + for theme in crate::theme::ThemeKind::ALL { + let mut h = harness(0, 3); + PhotocraftApp::setup_context(&h.ctx, theme); + let mut brush = h.state().session.tools.brush.clone(); + for i in 1..SECTIONS.len() { + *section_lock(&mut brush, i).unwrap() = i % 2 == 0; + } + h.state_mut().session.tools.brush = brush.clone(); + let presets = h.state().session.tools.presets.clone(); + let journal = h.state().session.journal.len(); + h.run_steps(3); + for (i, &(name, _)) in SECTIONS.iter().enumerate().skip(1) { + let label = format!("Lock: {name}"); + let node = h.get_by_role_and_label(Role::Button, &label); + assert!(!node.accesskit_node().is_disabled(), "locks remain available for disabled sections"); + assert_eq!(h.query_all_by_label(&label).count(), 1, "{theme:?}: {name} has one distinct control"); + assert_eq!(node.accesskit_node().toggled(), Some(if i % 2 == 0 { Toggled::True } else { Toggled::False }), "{theme:?}: {name}"); + let row = h.get_by_label(name).rect(); + assert!(row.contains_rect(node.rect()), "the named control is the lock's existing hit target"); + } + assert_eq!(h.state().session.tools.brush, brush, "describing a lock must not edit it"); + assert_eq!(h.state().session.tools.presets, presets); + assert_eq!(h.state().session.journal.len(), journal); + assert_eq!(h.state().ui.brush_section, 3); + } +} + +#[test] +fn section_lock_buttons_accept_keyboard_without_selecting_or_enabling_sections() { + use egui::accesskit::{Role, Toggled}; + use egui_kittest::kittest::{NodeT, Queryable}; + + for (i, &(name, _)) in SECTIONS.iter().enumerate().skip(1) { + let mut h = harness(0, 0); + let before = h.state().session.tools.brush.clone(); + let presets = h.state().session.tools.presets.clone(); + let journal = h.state().session.journal.len(); + let label = format!("Lock: {name}"); + let mut replay = photocraft_engine::Session::new(); + replay.tools.brush = before.clone(); + h.get_by_role_and_label(Role::Button, &label).focus(); + h.run_steps(2); + for locked in [true, false] { + h.key_press(egui::Key::Space); + h.run_steps(3); + let mut expected = before.clone(); + *section_lock(&mut expected, i).unwrap() = locked; + assert_eq!(h.state().session.tools.brush, expected, "{name}: only this section's lock changes"); + assert_eq!(h.state().ui.brush_section, 0); + assert_eq!(h.state().session.tools.presets, presets); + assert_eq!(h.get_by_role_and_label(Role::Button, &label).accesskit_node().toggled(), Some(if locked { Toggled::True } else { Toggled::False })); + let (id, params) = last_journal(h.state()).unwrap(); + assert_eq!(id, "tools.setBrush"); + assert_eq!(params["brush"].as_object().unwrap().len(), 1); + assert_eq!(params["brush"]["locks"].as_object().unwrap().len(), 1); + assert_eq!(params["brush"]["locks"].as_object().unwrap().values().next(), Some(&json!(locked))); + replay.execute(&id, params).unwrap(); + assert_eq!(replay.tools.brush, expected, "command replay preserves the lock-only edit"); + } + assert_eq!(h.state().session.journal.len(), journal + 2, "one command per activation"); + assert_eq!(replay.tools.brush, before); + } +} + /// A harness that shows the options bar with `tool` in the Studio (non-pro) theme. fn options_bar_harness(tool: crate::state::Tool) -> Harness<'static, PhotocraftApp> { let mut app = app(); diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 7a5e499820..207301eb04 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-13 · **Change:** minor (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) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (named, stateful Brush Settings section locks; 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) · **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 @@ -30,7 +30,7 @@ tablets against Photoshop side by side. | Brush Pose (tilt, rotation, pressure overrides) | done | | | Noise, Wet Edges, Build-up, Protect Texture | done | | | Smoothing: amount, Pulled String, Stroke Catch-up, Catch-up on End, Adjust for Zoom | done | `brush.rs:428-438` | -| Section locks | done | #174 | +| Section locks | done | #174; `brush_panel_tests.rs` verifies section-specific accessible names and locked state for all 12 controls in 14 themes, plus Space activation, minimal command patches and replay without selecting or enabling a section. | | Noise, Wet Edges, Build-up, Smoothing and Protect Texture: checkbox-only rows keep the selected controls open | done | `brush_panel_tests.rs`, #4283; Smoothing enable/disable preserves its amount and options, including imported ABR tool settings | | Brush tip from an image file | missing | TOOL-213-12 | | Erodible, Airbrush and Bristle tips (Photoshop's special tips) | partial | not audited in detail; "3D-based brushes don't work" (#1660) | @@ -121,3 +121,4 @@ 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-14 | minor | The 12 existing Brush Settings section locks expose translated names and locked state to AccessKit (#3477 workflow); icons, hit targets and command behavior retained. No brush-feel estimate changed. | diff --git a/docs/gaps.md b/docs/gaps.md index 25a286aab8..88ec068693 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-13 · **Change:** minor (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) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (G4: named, stateful section locks in Brush Settings; 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) · **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 @@ -156,6 +156,10 @@ 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-14, #3477 workflow):** section locks expose their section-specific + translated names and locked state to AccessKit. Keyboard activation preserves the selected + section and other brush settings, using the existing command path. Other panel 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. @@ -263,6 +267,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | G4: existing Brush Settings section locks expose accessible names/state (#3477 workflow); no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | | 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done | From 61b0f4699f6d358586db5d887a6817076dcc3b91 Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Wed, 14 Oct 2026 05:12:20 +0300 Subject: [PATCH 2/4] Brush Settings: show keyboard focus on section locks Co-authored-by: GPT-6 --- crates/ui-egui/src/brush_panel.rs | 3 ++ crates/ui-egui/src/brush_panel_tests.rs | 47 +++++++++++++++++++++++++ docs/brush-parity.md | 6 ++-- docs/gaps.md | 7 ++-- 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/brush_panel.rs b/crates/ui-egui/src/brush_panel.rs index 7c44749565..c807f107ae 100644 --- a/crates/ui-egui/src/brush_panel.rs +++ b/crates/ui-egui/src/brush_panel.rs @@ -328,6 +328,9 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting // Like Photoshop: lock icons stay visible on every lockable section row. let tint = if *lock { t.text } else { t.text_faint }; icons::paint(ui, lr, if *lock { "lock" } else { "lock-open" }, 12.0, tint); + if lresp.has_focus() { + ui.painter().rect_stroke(lr, t.radius_sm, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + } let tip = if *lock { tl!("Unlock: picking a preset replaces these settings") } else { tl!("Lock: keep these settings when picking another preset") }; if lresp.on_hover_text(tip).clicked() { diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 7ad74fb1d8..e260cecf91 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -344,6 +344,53 @@ fn section_lock_buttons_accept_keyboard_without_selecting_or_enabling_sections() } } +#[test] +fn section_lock_buttons_show_focus_and_track_keyboard_navigation() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + + fn has_ring(shape: &egui::Shape, target: egui::Rect, color: egui::Color32) -> bool { + match shape { + egui::Shape::Rect(r) => r.rect == target && r.stroke == Stroke::new(1.0, color) && r.stroke_kind == egui::StrokeKind::Inside, + egui::Shape::Vec(v) => v.iter().any(|s| has_ring(s, target, color)), + _ => false, + } + } + for (index, theme) in crate::theme::ThemeKind::ALL.into_iter().enumerate() { + let mut h = harness(0, 0); + PhotocraftApp::setup_context(&h.ctx, theme); + h.state_mut().session.tools.brush.locks.scattering = index % 2 == 0; + h.state_mut().session.tools.brush.locks.texture = index % 2 != 0; + h.run_steps(2); + let before = h.state().session.tools.brush.clone(); + let presets = h.state().session.tools.presets.clone(); + let journal = h.state().session.journal.len(); + let first = h.get_by_role_and_label(Role::Button, "Lock: Scattering").rect(); + let second = h.get_by_role_and_label(Role::Button, "Lock: Texture").rect(); + let accent = Tokens::get(&h.ctx).accent; + let ring = |h: &Harness<'_, PhotocraftApp>, rect| h.output().shapes.iter().any(|s| s.clip_rect.contains_rect(rect) && has_ring(&s.shape, rect, accent)); + assert!(!ring(&h, first) && !ring(&h, second), "{theme:?}: unfocused locks have no focus ring"); + h.get_by_role_and_label(Role::Button, "Lock: Scattering").focus(); + h.run_steps(2); + assert!(h.get_by_role_and_label(Role::Button, "Lock: Scattering").is_focused()); + assert!(ring(&h, first) && !ring(&h, second), "{theme:?}: the focused lock needs a visible indicator"); + h.key_press(egui::Key::Tab); + h.run_steps(2); + assert!(!h.get_by_role_and_label(Role::Button, "Lock: Scattering").is_focused()); + assert!(!ring(&h, first), "Tab removes the old lock's indicator"); + h.get_by_role_and_label(Role::Button, "Lock: Texture").focus(); + h.run_steps(2); + assert!(!ring(&h, first) && ring(&h, second), "the ring follows focus"); + h.get_by_label("Brush Tip Shape").focus(); + h.run_steps(2); + assert!(!ring(&h, first) && !ring(&h, second)); + assert_eq!(h.state().session.tools.brush, before, "focus must not edit settings"); + assert_eq!(h.state().session.tools.presets, presets); + assert_eq!(h.state().session.journal.len(), journal); + assert_eq!(h.state().ui.brush_section, 0); + } +} + /// A harness that shows the options bar with `tool` in the Studio (non-pro) theme. fn options_bar_harness(tool: crate::state::Tool) -> Harness<'static, PhotocraftApp> { let mut app = app(); diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 207301eb04..8e02a4e272 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 (named, stateful Brush Settings section locks; 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) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (named, stateful Brush Settings locks and visible keyboard focus; 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) · **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 @@ -30,7 +30,7 @@ tablets against Photoshop side by side. | Brush Pose (tilt, rotation, pressure overrides) | done | | | Noise, Wet Edges, Build-up, Protect Texture | done | | | Smoothing: amount, Pulled String, Stroke Catch-up, Catch-up on End, Adjust for Zoom | done | `brush.rs:428-438` | -| Section locks | done | #174; `brush_panel_tests.rs` verifies section-specific accessible names and locked state for all 12 controls in 14 themes, plus Space activation, minimal command patches and replay without selecting or enabling a section. | +| Section locks | done | #174; `brush_panel_tests.rs` verifies section-specific accessible names and locked state for all 12 controls in 14 themes, plus visible themed focus, Tab blur, Space activation, minimal command patches and replay without selecting or enabling a section. | | Noise, Wet Edges, Build-up, Smoothing and Protect Texture: checkbox-only rows keep the selected controls open | done | `brush_panel_tests.rs`, #4283; Smoothing enable/disable preserves its amount and options, including imported ABR tool settings | | Brush tip from an image file | missing | TOOL-213-12 | | Erodible, Airbrush and Bristle tips (Photoshop's special tips) | partial | not audited in detail; "3D-based brushes don't work" (#1660) | @@ -121,4 +121,4 @@ 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-14 | minor | The 12 existing Brush Settings section locks expose translated names and locked state to AccessKit (#3477 workflow); icons, hit targets and command behavior retained. No brush-feel estimate changed. | +| 2026-10-14 | minor | The 12 existing Brush Settings section locks expose translated names and locked state to AccessKit and show themed keyboard focus (#3477 workflow); icons, hit targets and command behavior retained. No brush-feel estimate changed. | diff --git a/docs/gaps.md b/docs/gaps.md index 88ec068693..307b8c25b1 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-14 · **Change:** minor (G4: named, stateful section locks in Brush Settings; 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) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (G4: named, stateful section locks and visible keyboard focus in Brush Settings; 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) · **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 @@ -157,7 +157,8 @@ what an agent can't finish alone. ### G4. Brush feel - **Progress (2026-10-14, #3477 workflow):** section locks expose their section-specific - translated names and locked state to AccessKit. Keyboard activation preserves the selected + translated names and locked state to AccessKit. A themed outline follows keyboard focus; + focus changes do not edit settings. Keyboard activation preserves the selected section and other brush settings, using the existing command path. Other panel and brush-feel gaps remain open. - **Progress (2026-10-13, #3825 partial):** Hue Jitter shows a standard themed @@ -267,7 +268,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| -| 2026-10-14 | minor | G4: existing Brush Settings section locks expose accessible names/state (#3477 workflow); no readiness percentage or estimate changed | +| 2026-10-14 | minor | G4: existing Brush Settings section locks expose accessible names/state and visible keyboard focus (#3477 workflow); no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | | 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done | From b32563501ddb557f33bf311cf4a2a34822330cb0 Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:12:44 +0300 Subject: [PATCH 3/4] test(autosave): wait for the retried snapshot metadata Recovery discovery can list a committed bundle before its sidecar is published. Wait for the expected revision rather than treating a fallback entry as write completion. Keep all failure, retry, revision and pixel assertions. Co-authored-by: GPT-6 --- apps/photocraft/src/services.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d4a3869f31..b7a5a4cb85 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -932,7 +932,9 @@ mod tests { while std::time::Instant::now() < deadline { prefs_ui::tick(&mut app, &ctx); recovered = list_recovery(&recovery); - if !recovered.is_empty() { + // A committed bundle is recoverable before its sidecar is published. Wait for + // the sidecar's revision, not the early fallback entry (revision 0). + if recovered.len() == 1 && recovered[0].info.revision == revision { break; } std::thread::sleep(std::time::Duration::from_millis(10)); From 86d5d9c6f2119047f7bdaabe9fe2eab870eedfbb Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:20:54 +0300 Subject: [PATCH 4/4] test(autosave): align retry coverage with upstream Upstream 7250e6e2 independently fixed the same manifest/sidecar race. Adopt its canonical test, including the nonzero revision guard, to clear the conflict while preserving the Brush Settings changes. Co-authored-by: GPT-6 --- apps/photocraft/src/services.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index b7a5a4cb85..e6c882d198 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -907,6 +907,7 @@ mod tests { let mut app = launch(&recovery); new_doc(&mut app, "#ff0000"); let revision = app.session.active().unwrap().revision; + assert_ne!(revision, 0, "a sidecar-less bundle lists as revision 0"); autosave(&mut app, &ctx); let mut failed = false; @@ -927,14 +928,16 @@ mod tests { std::fs::remove_file(&recovery).unwrap(); std::fs::create_dir(&recovery).unwrap(); autosave(&mut app, &ctx); + // The worker commits the bundle's manifest before it publishes the sidecar, and + // `list_recovery` lists a committed sidecar-less bundle (revision 0) on its own, so + // the first non-empty listing can come from the middle of the write. Wait for the write + // to finish: the entry then carries the sidecar's revision. let mut recovered = Vec::new(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); while std::time::Instant::now() < deadline { prefs_ui::tick(&mut app, &ctx); recovered = list_recovery(&recovery); - // A committed bundle is recoverable before its sidecar is published. Wait for - // the sidecar's revision, not the early fallback entry (revision 0). - if recovered.len() == 1 && recovered[0].info.revision == revision { + if recovered.iter().any(|e| e.info.revision == revision) { break; } std::thread::sleep(std::time::Duration::from_millis(10));