From 436664a13ff263b42c32e3c505a4fa9ca1a1288d Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Fri, 20 Mar 2026 00:24:48 +0800 Subject: [PATCH 1/2] {"schema":"delivery/1","type":"fix","scope":"frozen-toolbar","summary":"recenter frozen toolbar after late width changes","intent":"keep the default frozen toolbar midpoint aligned with the capture midpoint across readiness transitions","impact":"drag-region frozen captures stay centered when Auto Center appears later and manual toolbar moves stay intact","breaking":false,"risk":"low","authority":"linear","delivery_mode":"status-only","refs":[{"system":"linear","id":"XY-184","role":"authority"}]} --- ...03-19_xy-184-frozen-toolbar-centering.json | 190 ++++++++++++++++++ packages/rsnap-overlay/src/overlay.rs | 134 +++++++++++- .../src/overlay/session_state.rs | 2 + 3 files changed, 320 insertions(+), 6 deletions(-) create mode 100644 docs/plans/2026-03-19_xy-184-frozen-toolbar-centering.json diff --git a/docs/plans/2026-03-19_xy-184-frozen-toolbar-centering.json b/docs/plans/2026-03-19_xy-184-frozen-toolbar-centering.json new file mode 100644 index 00000000..781eb092 --- /dev/null +++ b/docs/plans/2026-03-19_xy-184-frozen-toolbar-centering.json @@ -0,0 +1,190 @@ +{ + "spec": { + "schema": "plan/1", + "plan_id": "2026-03-19-xy-184-frozen-toolbar-centering", + "goal": "Make the Frozen toolbar obey the exact XY-184 contract that the toolbar's horizontal midpoint matches the frozen capture region's horizontal midpoint.", + "success_criteria": [ + "For Frozen captures, the toolbar's horizontal midpoint equals the frozen capture region's horizontal midpoint whenever the default slot is used.", + "The default horizontal position no longer depends on detected content bounds or other visual heuristics.", + "Frozen entry and later frozen-rect updates continue to keep toolbar placement synchronized with the current frozen capture rect.", + "Focused regression tests lock in exact midpoint equality rather than heuristic content bias." + ], + "constraints": [ + "Keep the change scoped to Frozen toolbar placement in rsnap-overlay.", + "Do not change the frozen capture rect, export crop, or screenshot pixels as part of this fix.", + "Do not widen the task into a badge-placement redesign or broader toolbar visual restyling.", + "Preserve manual toolbar dragging semantics once the user has moved the toolbar away from its default slot.", + "Use repo-native verification commands before claiming completion." + ], + "defaults": { + "authority_linear_issue": "XY-184", + "branch": "y/xy-184-frozen-toolbar-centering", + "issue_url": "https://linear.app/hack-ink/issue/XY-184/investigate-perceived-horizontal-mis-centering-of-the-frozen-toolbar", + "primary_files": [ + "packages/rsnap-overlay/src/overlay.rs" + ], + "verification_commands": [ + "cargo test -p rsnap-overlay --lib frozen_toolbar", + "cargo test -p rsnap-overlay --lib auto_center", + "cargo make fmt", + "cargo make lint-rust", + "git diff --check" + ] + }, + "tasks": [ + { + "id": "task-1", + "title": "Restore exact geometric midpoint anchoring for the Frozen toolbar", + "status": "done", + "objective": "Remove the content-aware Frozen toolbar x-anchor path and make the default toolbar x position depend only on the frozen capture rect midpoint.", + "inputs": [ + "Current Frozen toolbar default position helpers in packages/rsnap-overlay/src/overlay.rs", + "User clarification that toolbar midpoint must exactly match capture midpoint", + "XY-184 issue scope" + ], + "outputs": [ + "A Frozen toolbar default-position path that uses only the frozen capture rect midpoint for x anchoring" + ], + "verification": [ + "The toolbar default x midpoint equals the frozen capture rect midpoint.", + "No content-detection heuristic participates in toolbar x anchoring." + ], + "depends_on": [] + }, + { + "id": "task-2", + "title": "Update regression coverage to assert exact midpoint equality", + "status": "done", + "objective": "Replace the previous content-bias tests with focused regression coverage that asserts exact toolbar midpoint equality against the frozen capture rect.", + "inputs": [ + "Task 1 implementation" + ], + "outputs": [ + "Regression tests for exact frozen toolbar midpoint anchoring" + ], + "verification": [ + "The regression suite fails if toolbar midpoint equality is broken." + ], + "depends_on": [ + "task-1" + ] + }, + { + "id": "task-3", + "title": "Run repo-native verification and record execution evidence", + "status": "done", + "objective": "Run the agreed verification commands on the revised XY-184 diff and update the saved plan runtime state with the resulting evidence.", + "inputs": [ + "Final XY-184 implementation" + ], + "outputs": [ + "Fresh verification evidence and updated runtime state" + ], + "verification": [ + "cargo test -p rsnap-overlay --lib frozen_toolbar exits 0", + "cargo test -p rsnap-overlay --lib auto_center exits 0", + "cargo make fmt exits 0", + "cargo make lint-rust exits 0", + "git diff --check returns clean" + ], + "depends_on": [ + "task-2" + ] + }, + { + "id": "task-4", + "title": "Recenter the default Frozen toolbar when late-appearing tools change its width", + "status": "done", + "objective": "Keep the toolbar midpoint aligned with the frozen capture midpoint when the toolbar is still in its default slot and the available Frozen tool set changes after the initial seed.", + "inputs": [ + "Current Frozen toolbar birth and redraw flow in packages/rsnap-overlay/src/overlay.rs", + "Evidence that drag-region captures seed before frozen_image is ready, so Auto Center appears later and widens the toolbar" + ], + "outputs": [ + "A default-slot recenter path that preserves exact midpoint equality across Frozen readiness transitions without breaking manual dragging" + ], + "verification": [ + "Drag-region Frozen captures remain centered after Auto Center becomes available.", + "Manual toolbar dragging still prevents automatic repositioning." + ], + "depends_on": [ + "task-3" + ] + }, + { + "id": "task-5", + "title": "Add a regression for post-seed width changes and rerun verification", + "status": "done", + "objective": "Cover the late-tool-availability centering regression with a focused test and rerun the repo-native verification commands on the updated fix.", + "inputs": [ + "Task 4 implementation" + ], + "outputs": [ + "Regression coverage for width-change recentering and fresh verification evidence" + ], + "verification": [ + "A focused regression fails if the toolbar width changes without preserving capture-midpoint alignment.", + "cargo test -p rsnap-overlay --lib frozen_toolbar exits 0", + "cargo test -p rsnap-overlay --lib auto_center exits 0", + "cargo make fmt exits 0", + "cargo make lint-rust exits 0", + "git diff --check returns clean" + ], + "depends_on": [ + "task-4" + ] + } + ], + "replan_policy": { + "owner": "plan-writing", + "triggers": [ + "The exact midpoint-equality contract still feels wrong in the product and the issue turns out to require a broader UX change rather than strict geometric centering.", + "A correct fix requires changing the saved XY-171 badge-alignment contract or broader Frozen toolbar visual language." + ] + } + }, + "state": { + "phase": "done", + "current_task_id": null, + "next_task_id": null, + "blockers": [], + "evidence": [ + "Implemented content-aware Frozen toolbar x-anchor selection in packages/rsnap-overlay/src/overlay.rs with geometric fallback via WindowRenderer::frozen_toolbar_default_x().", + "Updated Frozen-ready handling so handle_captured_freeze_response() recomputes the preseeded toolbar position once final frozen pixels are available.", + "Added focused regression tests for asymmetric content anchoring, geometric fallback, and post-freeze toolbar repositioning.", + "Verified with cargo test -p rsnap-overlay --lib frozen_toolbar (exit 0).", + "Verified with cargo test -p rsnap-overlay --lib auto_center (exit 0).", + "Verified with cargo test -p rsnap-overlay --lib captured_freeze_response_repositions_preseeded_toolbar_to_content_anchor (exit 0).", + "Verified with cargo make fmt (exit 0).", + "Verified with cargo make lint-rust (exit 0).", + "Verified with git diff --check (clean).", + "Replan evidence: user clarified that XY-184 requires exact midpoint equality between the toolbar and frozen capture rect, which invalidates the prior content-aware anchor strategy.", + "Task 1 outcome: packages/rsnap-overlay/src/overlay.rs now uses only the frozen capture rect midpoint for the default toolbar x anchor and no longer recomputes a content-aware x anchor from final frozen pixels.", + "Task 2 outcome: replaced the prior content-bias regressions with a focused midpoint-equality test for the Frozen toolbar default position.", + "Task 3 verification: cargo test -p rsnap-overlay --lib frozen_toolbar exited 0 after restoring exact midpoint anchoring.", + "Task 3 verification: cargo test -p rsnap-overlay --lib auto_center exited 0 after removing the content-aware toolbar path.", + "Task 3 verification: cargo make fmt exited 0 after the midpoint-anchor rollback.", + "Task 3 verification: cargo make lint-rust exited 0 after the midpoint-anchor rollback.", + "Task 3 verification: git diff --check returned clean after the midpoint-anchor rollback.", + "Replan evidence: drag-region Frozen captures seed the toolbar before state.finish_freeze() publishes frozen_image, so frozen_auto_center_available() is false at seed time and true later.", + "Replan evidence: when the Auto Center tool appears later, the toolbar width grows but the stored floating_position continues to be reused as the left edge, which shifts the toolbar midpoint to the right of the capture midpoint.", + "Task 4 outcome: the Frozen toolbar now records its last default-slot position and recenters only when it still matches that default slot after a late tool-set width change.", + "Task 4 verification: cargo test -p rsnap-overlay --lib drag_region_toolbar_recenters_when_auto_center_appears_after_preview_commit exited 0.", + "Task 4 verification: cargo test -p rsnap-overlay --lib late_toolbar_width_change_preserves_manual_toolbar_move exited 0.", + "Task 5 verification: cargo test -p rsnap-overlay --lib frozen_toolbar exited 0 after the late-width recenter fix.", + "Task 5 verification: cargo test -p rsnap-overlay --lib auto_center exited 0 after the late-width recenter fix.", + "Task 5 verification: cargo make fmt exited 0 after the late-width recenter fix.", + "Task 5 verification: cargo make lint-rust exited 0 after the late-width recenter fix.", + "Task 5 verification: git diff --check returned clean after the late-width recenter fix.", + "Task 5 verification: cargo make build-release exited 0 after the late-width recenter fix." + ], + "last_updated": "2026-03-20T03:34:00Z", + "replan_reason": null, + "context_snapshot": { + "branch": "y/xy-184-frozen-toolbar-centering", + "current_theory": "The right-bias was caused by seeding drag-region Frozen toolbar position before frozen_image existed; Auto Center then appeared later, widened the toolbar, and reused the old left edge until we taught the default slot to recenter across that width transition.", + "issue": "XY-184", + "workspace": ".workspaces/xy-184-frozen-toolbar-centering" + } + } +} diff --git a/packages/rsnap-overlay/src/overlay.rs b/packages/rsnap-overlay/src/overlay.rs index 39157fe5..c0641cee 100644 --- a/packages/rsnap-overlay/src/overlay.rs +++ b/packages/rsnap-overlay/src/overlay.rs @@ -2755,6 +2755,7 @@ impl OverlaySession { let default_pos = self.frozen_toolbar_default_position_for_capture_rect(monitor, capture_rect); + self.toolbar_state.default_slot_position = Some(default_pos); self.toolbar_state.floating_position = Some(default_pos); let _ = self.update_toolbar_outer_position(monitor, default_pos); @@ -2772,13 +2773,13 @@ impl OverlaySession { fn frozen_toolbar_default_position_for_capture_rect( &self, monitor: MonitorRect, - capture_rect: RectPoints, + capture_rect_points: RectPoints, ) -> Pos2 { let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(monitor.width as f32, monitor.height as f32)); let capture_rect = Rect::from_min_size( - Pos2::new(capture_rect.x as f32, capture_rect.y as f32), - Vec2::new(capture_rect.width as f32, capture_rect.height as f32), + Pos2::new(capture_rect_points.x as f32, capture_rect_points.y as f32), + Vec2::new(capture_rect_points.width as f32, capture_rect_points.height as f32), ); let toolbar_size = WindowRenderer::frozen_toolbar_size(&self.toolbar_state); @@ -2854,6 +2855,7 @@ impl OverlaySession { ); self.toolbar_state.floating_position = None; + self.toolbar_state.default_slot_position = None; self.toolbar_state.dragging = false; self.toolbar_state.needs_redraw = true; self.toolbar_state.pill_height_points = None; @@ -3074,6 +3076,7 @@ impl OverlaySession { let toolbar_pos = self.frozen_toolbar_default_position_for_capture_rect(monitor, next_rect); + self.toolbar_state.default_slot_position = Some(toolbar_pos); self.toolbar_state.floating_position = Some(toolbar_pos); let _ = self.update_toolbar_outer_position(monitor, toolbar_pos); @@ -3895,6 +3898,7 @@ impl OverlaySession { toolbar_input: Option, ) -> Result<()> { self.sync_frozen_toolbar_state(); + self.maybe_recenter_frozen_toolbar_default_slot(monitor); #[cfg(not(target_os = "macos"))] { @@ -6488,11 +6492,39 @@ impl OverlaySession { self.config.toolbar_placement, ); + self.toolbar_state.default_slot_position = Some(toolbar_pos); self.toolbar_state.floating_position = Some(toolbar_pos); let _ = self.update_toolbar_outer_position(monitor, toolbar_pos); } + fn maybe_recenter_frozen_toolbar_default_slot(&mut self, monitor: MonitorRect) { + if !matches!(self.state.mode, OverlayMode::Frozen) || self.state.monitor != Some(monitor) { + return; + } + if self.scroll_capture.active || self.toolbar_state.dragging { + return; + } + + let Some(capture_rect) = self.state.frozen_capture_rect else { + return; + }; + let Some(toolbar_pos) = self.toolbar_state.floating_position else { + return; + }; + let Some(previous_default_pos) = self.toolbar_state.default_slot_position else { + return; + }; + let current_default_pos = + self.frozen_toolbar_default_position_for_capture_rect(monitor, capture_rect); + + self.toolbar_state.default_slot_position = Some(current_default_pos); + + if frozen_toolbar_matches_default_slot(toolbar_pos, previous_default_pos) { + self.toolbar_state.floating_position = Some(current_default_pos); + } + } + fn handle_overlay_window_redraw(&mut self, window_id: WindowId) -> OverlayControl { let Some(overlay_monitor) = self.windows.get(&window_id).map(|overlay| overlay.monitor) else { @@ -10134,6 +10166,7 @@ impl WindowRenderer { "Frozen toolbar birth resolved." ); + toolbar_state.default_slot_position = Some(default_pos); toolbar_state.floating_position = Some(default_pos); Some(default_pos) @@ -10188,16 +10221,25 @@ impl WindowRenderer { if within_screen { above_y } else { capture_rect.min.y + TOOLBAR_SCREEN_MARGIN_PX } }, }; - let min_x = screen_rect.min.x + TOOLBAR_SCREEN_MARGIN_PX; let min_y = screen_rect.min.y + TOOLBAR_SCREEN_MARGIN_PX; - let max_x = (screen_rect.max.x - toolbar_size.x - TOOLBAR_SCREEN_MARGIN_PX).max(min_x); let max_y = (screen_rect.max.y - toolbar_size.y - TOOLBAR_SCREEN_MARGIN_PX).max(min_y); - let x = (capture_rect.center().x - toolbar_size.x / 2.0).clamp(min_x, max_x); + let x = Self::frozen_toolbar_default_x(screen_rect, toolbar_size, capture_rect.center().x); let y = y.max(min_y).min(max_y); Pos2::new(x, y) } + fn frozen_toolbar_default_x( + screen_rect: Rect, + toolbar_size: Vec2, + anchor_center_x: f32, + ) -> f32 { + let min_x = screen_rect.min.x + TOOLBAR_SCREEN_MARGIN_PX; + let max_x = (screen_rect.max.x - toolbar_size.x - TOOLBAR_SCREEN_MARGIN_PX).max(min_x); + + (anchor_center_x - toolbar_size.x / 2.0).clamp(min_x, max_x) + } + #[allow(clippy::too_many_arguments)] fn draw_frozen_toolbar( ctx: &egui::Context, @@ -12821,6 +12863,20 @@ mod tests { assert_eq!(session.toolbar_state.floating_position, Some(expected_toolbar_pos)); } + #[test] + fn frozen_toolbar_default_position_centers_on_capture_rect_midpoint() { + let monitor = test_monitor_with_scale(400, 300, 2_000); + let capture_rect = RectPoints::new(150, 100, 100, 60); + let session = OverlaySession::new(); + let toolbar_size = WindowRenderer::frozen_toolbar_size(&session.toolbar_state); + let toolbar_pos = + session.frozen_toolbar_default_position_for_capture_rect(monitor, capture_rect); + let toolbar_midpoint_x = toolbar_pos.x + toolbar_size.x * 0.5; + let capture_midpoint_x = capture_rect.x as f32 + capture_rect.width as f32 * 0.5; + + assert_eq!(toolbar_midpoint_x, capture_midpoint_x); + } + #[test] fn auto_center_frozen_capture_rect_noops_for_uniform_crop() { let monitor = test_monitor_with_scale(80, 60, 1_000); @@ -14065,6 +14121,72 @@ mod tests { assert_eq!(pending_toolbar_size, ready_toolbar_size); } + #[cfg(target_os = "macos")] + #[test] + fn drag_region_toolbar_recenters_when_auto_center_appears_after_preview_commit() { + let monitor = test_monitor(); + let capture_rect = RectPoints::new(120, 160, 320, 240); + let mut session = OverlaySession::new(); + + session.begin_frozen_capture_with_rect(monitor, Some(capture_rect), None, None); + + let seeded_pos = session + .toolbar_state + .floating_position + .expect("toolbar should seed before frozen preview is ready"); + let seeded_size = WindowRenderer::frozen_toolbar_size(&session.toolbar_state); + let capture_midpoint_x = capture_rect.x as f32 + capture_rect.width as f32 * 0.5; + + assert!(!session.toolbar_state.auto_center_available); + assert_eq!(seeded_pos.x + seeded_size.x * 0.5, capture_midpoint_x); + + session.commit_frozen_preview(monitor, test_frozen_image(), None); + session.sync_frozen_toolbar_state(); + + let ready_size = WindowRenderer::frozen_toolbar_size(&session.toolbar_state); + + assert!(session.toolbar_state.auto_center_available); + assert!(ready_size.x > seeded_size.x); + + session.maybe_recenter_frozen_toolbar_default_slot(monitor); + + let recentered_pos = session + .toolbar_state + .floating_position + .expect("toolbar should keep a default position"); + + assert_eq!(recentered_pos.x + ready_size.x * 0.5, capture_midpoint_x); + assert_eq!(session.toolbar_state.default_slot_position, Some(recentered_pos)); + } + + #[cfg(target_os = "macos")] + #[test] + fn late_toolbar_width_change_preserves_manual_toolbar_move() { + let monitor = test_monitor(); + let capture_rect = RectPoints::new(120, 160, 320, 240); + let mut session = OverlaySession::new(); + + session.begin_frozen_capture_with_rect(monitor, Some(capture_rect), None, None); + + let seeded_default_pos = session + .toolbar_state + .floating_position + .expect("toolbar should seed before frozen preview is ready"); + let moved_pos = seeded_default_pos + Vec2::new(24.0, 0.0); + + session.toolbar_state.floating_position = Some(moved_pos); + + session.commit_frozen_preview(monitor, test_frozen_image(), None); + session.sync_frozen_toolbar_state(); + session.maybe_recenter_frozen_toolbar_default_slot(monitor); + + assert_eq!(session.toolbar_state.floating_position, Some(moved_pos)); + assert_eq!( + session.toolbar_state.default_slot_position, + Some(session.frozen_toolbar_default_position_for_capture_rect(monitor, capture_rect)) + ); + } + #[test] fn auto_center_toolbar_tool_only_appears_when_available() { let default_tools = WindowRenderer::frozen_toolbar_tools(&FrozenToolbarState::default()); diff --git a/packages/rsnap-overlay/src/overlay/session_state.rs b/packages/rsnap-overlay/src/overlay/session_state.rs index 8d253f34..e6309a63 100644 --- a/packages/rsnap-overlay/src/overlay/session_state.rs +++ b/packages/rsnap-overlay/src/overlay/session_state.rs @@ -133,6 +133,7 @@ pub(super) struct FrozenToolbarState { pub(super) pending_action: Option, pub(super) needs_redraw: bool, pub(super) pill_height_points: Option, + pub(super) default_slot_position: Option, pub(super) floating_position: Option, pub(super) layout_last_screen_size_points: Option, pub(super) layout_stable_frames: u8, @@ -152,6 +153,7 @@ impl Default for FrozenToolbarState { pending_action: None, needs_redraw: false, pill_height_points: None, + default_slot_position: None, floating_position: None, layout_last_screen_size_points: None, layout_stable_frames: 0, From 69ceb48b88b3b7f1121b2ccdae44c3dcb3f9ddfd Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Fri, 20 Mar 2026 00:52:00 +0800 Subject: [PATCH 2/2] {"schema":"delivery/1","type":"fix","scope":"frozen-toolbar","summary":"redraw overlay after frozen toolbar recenter","intent":"keep overlay reservations in sync when late toolbar width changes recenter the default Frozen toolbar slot","impact":"Frozen size badges no longer lag the toolbar after late tool availability changes its width","breaking":false,"risk":"low","authority":"linear","delivery_mode":"status-only","refs":[{"system":"linear","id":"XY-184","role":"authority"}]} --- packages/rsnap-overlay/src/overlay.rs | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/rsnap-overlay/src/overlay.rs b/packages/rsnap-overlay/src/overlay.rs index c0641cee..f660b2af 100644 --- a/packages/rsnap-overlay/src/overlay.rs +++ b/packages/rsnap-overlay/src/overlay.rs @@ -3898,7 +3898,10 @@ impl OverlaySession { toolbar_input: Option, ) -> Result<()> { self.sync_frozen_toolbar_state(); - self.maybe_recenter_frozen_toolbar_default_slot(monitor); + + if self.maybe_recenter_frozen_toolbar_default_slot(monitor) { + self.request_redraw_for_monitor(monitor); + } #[cfg(not(target_os = "macos"))] { @@ -6498,22 +6501,22 @@ impl OverlaySession { let _ = self.update_toolbar_outer_position(monitor, toolbar_pos); } - fn maybe_recenter_frozen_toolbar_default_slot(&mut self, monitor: MonitorRect) { + fn maybe_recenter_frozen_toolbar_default_slot(&mut self, monitor: MonitorRect) -> bool { if !matches!(self.state.mode, OverlayMode::Frozen) || self.state.monitor != Some(monitor) { - return; + return false; } if self.scroll_capture.active || self.toolbar_state.dragging { - return; + return false; } let Some(capture_rect) = self.state.frozen_capture_rect else { - return; + return false; }; let Some(toolbar_pos) = self.toolbar_state.floating_position else { - return; + return false; }; let Some(previous_default_pos) = self.toolbar_state.default_slot_position else { - return; + return false; }; let current_default_pos = self.frozen_toolbar_default_position_for_capture_rect(monitor, capture_rect); @@ -6522,7 +6525,11 @@ impl OverlaySession { if frozen_toolbar_matches_default_slot(toolbar_pos, previous_default_pos) { self.toolbar_state.floating_position = Some(current_default_pos); + + return !frozen_toolbar_matches_default_slot(toolbar_pos, current_default_pos); } + + false } fn handle_overlay_window_redraw(&mut self, window_id: WindowId) -> OverlayControl { @@ -14147,8 +14154,7 @@ mod tests { assert!(session.toolbar_state.auto_center_available); assert!(ready_size.x > seeded_size.x); - - session.maybe_recenter_frozen_toolbar_default_slot(monitor); + assert!(session.maybe_recenter_frozen_toolbar_default_slot(monitor)); let recentered_pos = session .toolbar_state @@ -14178,8 +14184,8 @@ mod tests { session.commit_frozen_preview(monitor, test_frozen_image(), None); session.sync_frozen_toolbar_state(); - session.maybe_recenter_frozen_toolbar_default_slot(monitor); + assert!(!session.maybe_recenter_frozen_toolbar_default_slot(monitor)); assert_eq!(session.toolbar_state.floating_position, Some(moved_pos)); assert_eq!( session.toolbar_state.default_slot_position,