diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
 
 **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
 
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365)
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-27 · **Change:** minor (worksheet scrollbar capture cleanup, #2748) · **Target:** Microsoft Excel (Microsoft 365)
 
 GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
 is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
@@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
 
 | Date | What landed |
 |---|---|
+| 2026-10-27 | Both worksheet scrollbars release capture on button-up or focus loss, including logic-only frames. Native Wayland also cancels on pointer exit because the pinned event bridge can lose an outside release; other hosts keep held dragging across window exit (#2748) |
 | 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before |
 | 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) |
 | 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) |
@@ -242,6 +243,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-27 | minor | Progress log: worksheet scrollbar capture cleanup (#2748) |
 | 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column |
 | 2026-10-23 | minor | Progress log: accounting negatives in number text |
 | 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! |
diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs
--- a/apps/gridcraft/src/main.rs
+++ b/apps/gridcraft/src/main.rs
@@ -381,6 +381,12 @@ fn main() -> eframe::Result<()> {
                 let Some(mut app) = pending.take() else {
                     return Err("the app was already created".into());
                 };
+                #[cfg(all(unix, not(target_os = "macos")))]
+                {
+                    use winit::raw_window_handle::{HasDisplayHandle as _, RawDisplayHandle};
+                    app.services.scrollbar_release_on_pointer_gone =
+                        cc.display_handle().is_ok_and(|h| matches!(h.as_raw(), RawDisplayHandle::Wayland(_)));
+                }
                 SheetApp::setup_context_for_language(&cc.egui_ctx, app.ui.dark, app.ui.language);
                 if let Some(port) = control_port {
                     match control_server::start(port, control_token.clone(), cc.egui_ctx.clone()) {
diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs
--- a/crates/ui-egui/src/app.rs
+++ b/crates/ui-egui/src/app.rs
@@ -93,6 +93,7 @@ impl SheetApp {
 
     /// Per-frame logic (control channel, screenshots). Call before `ui`.
     pub fn logic(&mut self, ctx: &egui::Context) {
+        grid::cancel_scrollbar_drag(ctx);
         self.ensure_text_width(ctx);
         // A keytip control request lives one frame: the ribbon consumes it during `ui`, and
         // anything that never reached a control (collapsed ribbon, tab switch) is dropped.
diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs
--- a/crates/ui-egui/src/grid.rs
+++ b/crates/ui-egui/src/grid.rs
@@ -40,6 +40,7 @@ pub use interact::{follow_link, freeze_handles, point_move};
 pub(crate) use quadrant::paint_quadrant;
 pub(crate) use selection::paint_selection;
 pub use selection::selection_fill;
+pub(crate) use show::cancel_scrollbar_drag;
 pub(crate) use show::horizontal_scrollbar;
 pub use show::show;
 #[cfg(test)]
diff --git a/crates/ui-egui/src/grid/show.rs b/crates/ui-egui/src/grid/show.rs
--- a/crates/ui-egui/src/grid/show.rs
+++ b/crates/ui-egui/src/grid/show.rs
@@ -334,6 +334,143 @@ fn paint_panes(
     quads
 }
 
+#[derive(Clone, Copy)]
+struct ScrollbarCapture {
+    id: egui::Id,
+    started: u64,
+    release_on_pointer_gone: bool,
+    cancelled: bool,
+    was_focused: bool,
+}
+
+struct ScrollbarInput;
+
+impl egui::plugin::Plugin for ScrollbarInput {
+    fn debug_name(&self) -> &'static str {
+        "worksheet scrollbar capture"
+    }
+
+    fn input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
+        let key = egui::Id::unique(("worksheet_scrollbar_capture", raw.viewport_id));
+        let Some(mut capture) = ctx.data(|d| d.get_temp::<ScrollbarCapture>(key)) else { return };
+        // A delivered release completes the old gesture. Only an interruption that
+        // comes first cancels it; later events must not erase a completed track click.
+        let ending = raw.events.iter().find_map(|event| match event {
+            egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: false, .. } => Some(false),
+            egui::Event::WindowFocused(false) => Some(true),
+            egui::Event::PointerGone if capture.release_on_pointer_gone => Some(true),
+            _ => None,
+        });
+        if !capture.cancelled && !ending.unwrap_or(capture.was_focused && !raw.focused) {
+            if ending == Some(false) {
+                ctx.data_mut(|d| d.remove::<ScrollbarCapture>(key));
+            } else if raw.focused && !capture.was_focused {
+                capture.was_focused = true;
+                ctx.data_mut(|d| d.insert_temp(key, capture));
+            }
+            return;
+        }
+        // Use the affected viewport's input for root and immediate windows alike,
+        // before egui assigns any later press. Never alter another widget's drag.
+        let released = ctx.viewport_for(raw.viewport_id, |v| {
+            (v.interact_widgets.dragged.is_none_or(|id| id == capture.id) && v.input.pointer.primary_down()).then(|| {
+                let pos = v.input.pointer.interact_pos().or_else(|| v.input.pointer.press_origin()).unwrap_or_default();
+                let cancel_click = v.input.pointer.could_any_button_be_click().then(|| v.input.pointer.press_origin().unwrap_or(pos));
+                (pos, v.input.modifiers, cancel_click)
+            })
+        });
+        if let Some((pos, modifiers, cancel_click)) = released {
+            let release = egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers };
+            if let Some(origin) = cancel_click {
+                // egui has no cancelled release. Invalidate the old click without net
+                // pointer movement before releasing; a later real press resets this state.
+                let distance = ctx.options(|o| o.input_options.max_click_dist) + 1.0;
+                raw.events.splice(0..0, [egui::Event::PointerMoved(origin + vec2(distance, 0.0)), egui::Event::PointerMoved(pos), release]);
+            } else {
+                raw.events.insert(0, release);
+            }
+        }
+        ctx.data_mut(|d| d.remove::<ScrollbarCapture>(key));
+    }
+}
+
+/// Both worksheet scrollbars share capture cleanup. `PointerGone` alone is not a release on
+/// hosts with working pointer capture. The native shell enables that fallback only on Wayland:
+/// egui-winit drops MouseInput without a pointer position, leaving primary_down stale on return.
+fn stop_scrollbar_drag(ctx: &egui::Context) {
+    let capture = egui::Id::unique(("worksheet_scrollbar_capture", ctx.viewport_id()));
+    let Some(mut state) = ctx.data(|d| d.get_temp::<ScrollbarCapture>(capture)) else { return };
+    if ctx.dragged_id().is_none_or(|dragged| dragged == state.id) {
+        if ctx.input(|i| i.pointer.primary_down()) {
+            // A logic-only tick cannot process a release. Remember the cancellation
+            // until the next full pass also clears the old pending click target.
+            state.cancelled = true;
+            ctx.data_mut(|d| d.insert_temp(capture, state));
+        } else {
+            ctx.data_mut(|d| d.remove::<ScrollbarCapture>(capture));
+        }
+        ctx.stop_dragging();
+    } else {
+        ctx.data_mut(|d| d.remove::<ScrollbarCapture>(capture));
+    }
+}
+
+/// Logic-only ticks refresh raw focus but retain the previous full pass's UI events.
+pub(crate) fn cancel_scrollbar_drag(ctx: &egui::Context) {
+    let key = egui::Id::unique(("worksheet_scrollbar_capture", ctx.viewport_id()));
+    let Some(mut state) = ctx.data(|d| d.get_temp::<ScrollbarCapture>(key)) else { return };
+    if ctx.input(|i| i.raw.focused) {
+        if !state.was_focused {
+            state.was_focused = true;
+            ctx.data_mut(|d| d.insert_temp(key, state));
+        }
+    } else if state.was_focused {
+        stop_scrollbar_drag(ctx);
+    }
+}
+
+fn cancel_scrollbar_capture(ctx: &egui::Context, release_on_pointer_gone: bool) {
+    let capture = egui::Id::unique(("worksheet_scrollbar_capture", ctx.viewport_id()));
+    let Some(state) = ctx.data(|d| d.get_temp::<ScrollbarCapture>(capture)) else { return };
+    let frame = ctx.cumulative_frame_nr();
+    let cancelled = ctx.input(|i| {
+        // Web focus polling can append Focused(false) after pointerdown before
+        // the canvas ever gains focus. That is not a loss of this new gesture's focus.
+        let focus_seen = state.was_focused || i.events.iter().any(|event| matches!(event, egui::Event::WindowFocused(true)));
+        let interrupted = i.events.iter().rev().find_map(|event| match event {
+            // Only a capture born this frame can belong to a press after an interruption.
+            egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: true, .. } if state.started == frame => Some(false),
+            egui::Event::WindowFocused(false) if focus_seen => Some(true),
+            egui::Event::PointerGone if release_on_pointer_gone => Some(true),
+            _ => None,
+        });
+        (state.was_focused && (!i.raw.focused || !i.focused)) || !i.pointer.primary_down() || interrupted.unwrap_or(false)
+    });
+    if cancelled {
+        stop_scrollbar_drag(ctx);
+    }
+}
+
+fn scrollbar_dragging(ui: &egui::Ui, resp: &egui::Response, release_on_pointer_gone: bool) -> bool {
+    if ui.ctx().plugin_opt::<ScrollbarInput>().is_none() {
+        ui.ctx().add_plugin(ScrollbarInput);
+    }
+    if (resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_down())) || resp.dragged_by(egui::PointerButton::Primary) {
+        let capture = egui::Id::unique(("worksheet_scrollbar_capture", ui.ctx().viewport_id()));
+        let frame = ui.ctx().cumulative_frame_nr();
+        // Web pointerdown can precede canvas focus. Only a subsequent loss of
+        // observed focus cancels that gesture.
+        let was_focused = ui.input(|i| i.raw.focused && i.focused);
+        ui.ctx().data_mut(|d| {
+            if d.get_temp::<ScrollbarCapture>(capture).is_none_or(|state| state.id != resp.id) {
+                d.insert_temp(capture, ScrollbarCapture { id: resp.id, started: frame, release_on_pointer_gone, cancelled: false, was_focused });
+            }
+        });
+    }
+    cancel_scrollbar_capture(ui.ctx(), release_on_pointer_gone);
+    resp.dragged() && ui.ctx().dragged_id() == Some(resp.id)
+}
+
 /// The worksheet is painted virtually, so it needs its own scrollbar rather than a
 /// ScrollArea containing all million rows. Keep the scroll extent stable during a drag.
 fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &Geo, track_x: (f32, f32), limit: f32, t: &Tokens) {
@@ -348,19 +485,20 @@ fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &G
     let extent = (content - visible).max(scroll).max(0.0).min(limit);
     let id = ui.scope_id().with("vscroll");
     let resp = ui.interact(track, id, Sense::click_and_drag());
+    let dragging = scrollbar_dragging(ui, &resp, app.services.scrollbar_release_on_pointer_gone);
     let drag_id = id.with("extent");
     if resp.drag_started() {
         ui.ctx().data_mut(|d| d.insert_temp(drag_id, extent));
     }
-    let extent = if resp.dragged() { ui.ctx().data(|d| d.get_temp::<f32>(drag_id)).unwrap_or(extent) } else { extent };
+    let extent = if dragging { ui.ctx().data(|d| d.get_temp::<f32>(drag_id)).unwrap_or(extent) } else { extent };
     let thumb_h = (track.height() * visible / (extent + visible)).clamp(24.0_f32.min(track.height()), track.height());
     let travel = track.height() - thumb_h;
     let thumb_top = track.top() + if extent > 0.0 { travel * (scroll / extent).clamp(0.0, 1.0) } else { 0.0 };
     let thumb = Rect::from_min_size(pos2(track.left() + 3.0, thumb_top), vec2((track.width() - 6.0).max(0.0), thumb_h));
     ui.painter().rect_filled(track, 0.0, t.tab_bar);
-    ui.painter().rect_filled(thumb, 4.0, if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_disabled });
+    ui.painter().rect_filled(thumb, 4.0, if resp.hovered() || dragging { t.text_dim } else { t.text_disabled });
     let mut next = scroll;
-    if resp.dragged() && travel > 0.0 {
+    if dragging && travel > 0.0 {
         next += resp.drag_delta().y / travel * extent;
     } else if resp.clicked()
         && let Some(p) = resp.interact_pointer_pos()
@@ -401,11 +539,12 @@ pub(crate) fn horizontal_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, track:
     let extent = (content - visible).max(scroll).max(0.0);
     let id = ui.scope_id().with("hscroll");
     let resp = ui.interact(track, id, Sense::click_and_drag());
+    let dragging = scrollbar_dragging(ui, &resp, app.services.scrollbar_release_on_pointer_gone);
     let drag_id = id.with("extent");
     if resp.drag_started() {
         ui.ctx().data_mut(|d| d.insert_temp(drag_id, extent));
     }
-    let extent = if resp.dragged() { ui.ctx().data(|d| d.get_temp::<f32>(drag_id)).unwrap_or(extent) } else { extent };
+    let extent = if dragging { ui.ctx().data(|d| d.get_temp::<f32>(drag_id)).unwrap_or(extent) } else { extent };
     let thumb_w = (track.width() * visible / (extent + visible).max(1.0)).clamp(24.0_f32.min(track.width()), track.width());
     let travel = track.width() - thumb_w;
     let offset = if extent > 0.0 { travel * (scroll / extent).clamp(0.0, 1.0) } else { 0.0 };
@@ -414,9 +553,9 @@ pub(crate) fn horizontal_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, track:
     let thumb_left = if geo.rtl { track.right() - thumb_w - offset } else { track.left() + offset };
     let thumb = Rect::from_min_size(pos2(thumb_left, track.top() + 3.0), vec2(thumb_w, (track.height() - 6.0).max(0.0)));
     ui.painter().rect_filled(track, 0.0, t.tab_bar);
-    ui.painter().rect_filled(thumb, 4.0, if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_disabled });
+    ui.painter().rect_filled(thumb, 4.0, if resp.hovered() || dragging { t.text_dim } else { t.text_disabled });
     let mut next = scroll;
-    if resp.dragged() && travel > 0.0 {
+    if dragging && travel > 0.0 {
         next += dir * resp.drag_delta().x / travel * extent;
     } else if resp.clicked()
         && let Some(p) = resp.interact_pointer_pos()
diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs
--- a/crates/ui-egui/src/services.rs
+++ b/crates/ui-egui/src/services.rs
@@ -3,6 +3,9 @@ use super::*;
 /// Host services the platform shell provides (file pickers, opening URLs).
 #[derive(Default)]
 pub struct Services {
+    /// Native Wayland only: its event bridge can lose button releases after `PointerGone`.
+    /// Cancel worksheet scrollbar capture there; other hosts keep held drags across window exit.
+    pub scrollbar_release_on_pointer_gone: bool,
     /// Desktop: discover configured printers on a worker.
     pub printers: Option<printing::Discover>,
     /// Desktop: submit the PDF with the chosen settings on a worker.
diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs
--- a/crates/ui-egui/tests/it/main.rs
+++ b/crates/ui-egui/tests/it/main.rs
@@ -79,6 +79,7 @@ mod ribbon_layout;
 mod right_to_left;
 mod scroll_position;
 mod scrollbar_column_fill;
+mod scrollbar_release;
 mod selection;
 mod selection_fill;
 mod selection_glide;
diff --git a/crates/ui-egui/tests/it/scrollbar_release.rs b/crates/ui-egui/tests/it/scrollbar_release.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/tests/it/scrollbar_release.rs
@@ -0,0 +1,580 @@
+//! Scrollbar capture must not outlive the pointer gesture that started it.
+
+use crate::common::Harness;
+use egui::{Event, Modifiers, PointerButton, Pos2, Vec2, pos2, vec2};
+use gridcraft_engine::core::CellRef;
+
+#[derive(Clone, Copy, Debug)]
+enum Axis {
+    Vertical,
+    Horizontal,
+}
+
+impl Axis {
+    fn start(self, h: &Harness) -> Pos2 {
+        match self {
+            Self::Vertical => pos2(h.state().grid.rect.unwrap().right() + 7.0, h.state().grid.cells_rect.unwrap().top() + 8.0),
+            Self::Horizontal => {
+                let track = h.state().grid.hscroll.unwrap();
+                pos2(track.left() + 6.0, track.center().y)
+            }
+        }
+    }
+
+    fn movement(self) -> Vec2 {
+        match self {
+            Self::Vertical => vec2(0.0, 40.0),
+            Self::Horizontal => vec2(40.0, 0.0),
+        }
+    }
+
+    fn outside_track(self, p: Pos2) -> Pos2 {
+        match self {
+            Self::Vertical => pos2(p.x - 150.0, p.y),
+            Self::Horizontal => pos2(p.x, p.y - 150.0),
+        }
+    }
+
+    fn scroll(self, h: &Harness) -> f32 {
+        match self {
+            Self::Vertical => h.state().view().scroll.y,
+            Self::Horizontal => h.state().view().scroll.x,
+        }
+    }
+}
+
+fn events(h: &mut Harness, events: impl IntoIterator<Item = Event>) {
+    h.input_mut().events.extend(events);
+    h.run_steps(2);
+}
+
+fn button(pos: Pos2, pressed: bool) -> Event {
+    Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }
+}
+
+fn begin_drag(h: &mut Harness, axis: Axis) -> Pos2 {
+    let start = axis.start(h);
+    events(h, [Event::PointerMoved(start), button(start, true)]);
+    let p = start + axis.movement();
+    events(h, [Event::PointerMoved(p)]);
+    assert!(axis.scroll(h) > 0.0, "{axis:?}: the thumb drag started");
+    p
+}
+
+#[test]
+fn a_press_before_canvas_focus_can_start_a_scrollbar_drag() {
+    for axis in [Axis::Vertical, Axis::Horizontal] {
+        let mut h = crate::support::blank_harness(4);
+        let p = axis.start(&h);
+        h.input_mut().focused = false;
+        events(&mut h, [Event::PointerMoved(p), button(p, true)]);
+        // Web pointerdown can run the app before mousedown focuses the canvas.
+        h.input_mut().focused = true;
+        events(&mut h, [Event::WindowFocused(true), Event::PointerMoved(p + axis.movement())]);
+        assert!(axis.scroll(&h) > 0.0, "{axis:?}: gaining focus must preserve the activating drag");
+        let before = axis.scroll(&h);
+        h.input_mut().focused = false;
+        events(&mut h, []);
+        h.input_mut().focused = true;
+        events(&mut h, [Event::PointerMoved(p + axis.movement() * 2.0)]);
+        assert_eq!(axis.scroll(&h), before, "{axis:?}: later focus loss still cancels the gesture");
+    }
+}
+
+#[test]
+fn a_track_press_before_canvas_focus_still_pages_on_release() {
+    for axis in [Axis::Vertical, Axis::Horizontal] {
+        let mut h = crate::support::blank_harness(4);
+        let p = track_end(axis, &h);
+        h.input_mut().focused = false;
+        events(&mut h, [Event::PointerMoved(p), button(p, true)]);
+        h.input_mut().focused = true;
+        events(&mut h, [Event::WindowFocused(true), button(p, false)]);
+        assert!(axis.scroll(&h) > 0.0, "{axis:?}: gaining focus must preserve the activating track click");
+    }
+}
+
+#[test]
+fn polling_canvas_focus_after_pointerdown_preserves_the_activating_press() {
+    for track_click in [false, true] {
+        for axis in [Axis::Vertical, Axis::Horizontal] {
+            let mut h = crate::support::blank_harness(4);
+            let p = if track_click { track_end(axis, &h) } else { axis.start(&h) };
+            // eframe starts with raw.focused=true. Its pointerdown handler queues
+            // the press, then update_focus discovers the canvas is not focused.
+            h.input_mut().focused = false;
+            events(&mut h, [Event::PointerMoved(p), button(p, true), Event::WindowFocused(false)]);
+            h.input_mut().focused = true;
+            let next = if track_click { button(p, false) } else { Event::PointerMoved(p + axis.movement()) };
+            events(&mut h, [Event::WindowFocused(true), next]);
+            assert!(axis.scroll(&h) > 0.0, "{axis:?}: canvas focus polling must preserve activating click={track_click}");
+        }
+    }
+}
+
+#[test]
+fn focus_acquired_and_lost_after_a_new_press_in_one_batch_still_cancels_it() {
+    for axis in [Axis::Vertical, Axis::Horizontal] {
+        let mut h = crate::support::blank_harness(4);
+        let p = axis.start(&h);
+        h.input_mut().focused = false;
+        events(&mut h, [Event::PointerMoved(p), button(p, true), Event::WindowFocused(true), Event::WindowFocused(false)]);
+        h.input_mut().focused = true;
+        events(&mut h, [Event::WindowFocused(true), Event::PointerMoved(p + axis.movement())]);
+        assert_eq!(axis.scroll(&h), 0.0, "{axis:?}: observed acquisition followed by loss must cancel even in the press batch");
+    }
+}
+
+#[test]
+fn focus_acquired_during_hidden_ticks_is_tracked_before_a_later_loss() {
+    for axis in [Axis::Vertical, Axis::Horizontal] {
+        let mut h = crate::support::blank_harness(4);
+        let p = axis.start(&h);
+        h.input_mut().focused = false;
+        events(&mut h, [Event::PointerMoved(p), button(p, true)]);
+        events(&mut h, [Event::PointerMoved(p + axis.movement())]);
+        assert!(axis.scroll(&h) > 0.0, "{axis:?}: an unfocused activating drag must continue");
+        let ctx = h.ctx.clone();
+        let _ = ctx.run_logic(&egui::RawInput { focused: true, ..Default::default() }, |ctx| h.state_mut().logic(ctx));
+        let _ = ctx.run_logic(&egui::RawInput { focused: false, ..Default::default() }, |ctx| h.state_mut().logic(ctx));
+        assert!(ctx.dragged_id().is_none(), "{axis:?}: the later hidden focus loss ends capture");
+        let stopped = axis.scroll(&h);
+        h.input_mut().focused = true;
+        events(&mut h, [Event::PointerMoved(p + axis.movement() * 2.0)]);
+        assert_eq!(axis.scroll(&h), stopped, "{axis:?}: the hidden cancellation survives until input is processed");
+    }
+}
+
+#[test]
+fn non_primary_thumb_drags_keep_their_existing_behavior() {
+    for button_kind in [PointerButton::Secondary, PointerButton::Middle] {
+        for axis in [Axis::Vertical, Axis::Horizontal] {
+            let mut h = crate::support::blank_harness(4);
+            let p = axis.start(&h);
+            events(&mut h, [Event::PointerMoved(p), Event::PointerButton { pos: p, button: button_kind, pressed: true, modifiers: Modifiers::NONE }]);
+            events(&mut h, [Event::PointerMoved(p + axis.movement())]);
+            assert!(axis.scroll(&h) > 0.0, "{axis:?}: {button_kind:?} dragging must retain baseline behavior");
+        }
+    }
+}
+
+fn dragging(axis: Axis) -> (Harness, Pos2) {
+    dragging_with_policy(axis, false)
+}
+
+fn dragging_with_policy(axis: Axis, release_on_pointer_gone: bool) -> (Harness, Pos2) {
+    let mut h = crate::support::blank_harness(4);
+    h.state_mut().services.scrollbar_release_on_pointer_gone = release_on_pointer_gone;
+    let p = begin_drag(&mut h, axis);
+    (h, p)
+}
+
+#[test]
+fn releasing_outside_the_window_ends_both_scrollbar_drags() {
+    for axis in [Axis::Vertical, Axis::Horizontal] {
+        let (mut h, p) = dragging(axis);
+        let outside = match axis {
+            Axis::Vertical => pos2(1250.0, p.y),
+            Axis::Horizontal => pos2(p.x, 850.0),
+        };
+        events(&mut h, [Event::PointerMoved(outside), button(outside, false)]);
+        let stopped = axis.scroll(&h);
+        events(&mut h, [Event::PointerMoved(outside + axis.movement())]);
+        assert_eq!(axis.scroll(&h), stopped, "{axis:?}: mouse movement after release must not scroll");
+    }
+}
+
+#[test]
+fn a_held_drag_continues_outside_the_track() {
+    for axis in [Axis::Vertical, Axis::Horizontal] {
+        let (mut h, p) = dragging(axis);
+        let before = axis.scroll(&h);
+        let outside = axis.outside_track(p) + axis.movement();
+        events(&mut h, [Event::PointerMoved(outside)]);
+        assert!(axis.scroll(&h) > before, "{axis:?}: leaving the track while held must keep dragging");
+        events(&mut h, [button(outside, false)]);
+        assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::new(0, 0));
+    }
+}
+
+#[test]
+fn losing_focus_ends_both_scrollbar_drags_without_a_release_event() {
+    for axis in [Axis::Vertical, Axis::Horizontal] {
+        let (mut h, p) = dragging(axis);
+        events(&mut h, [Event::WindowFocused(false)]);
+        let stopped = axis.scroll(&h);
+        events(&mut h, [Event::WindowFocused(true), Event::PointerMoved(p + axis.movement())]);
+        assert_eq!(axis.scroll(&h), stopped, "{axis:?}: focus loss must release scrollbar capture");
+    }
+}
+
+#[test]
+fn a_release_after_pointer_gone_ends_both_scrollbar_drags() {
+    for axis in [Axis::Vertical, Axis::Horizontal] {
+        let (mut h, p) = dragging(axis);
+        events(&mut h, [Event::PointerGone]);
+        events(&mut h, [button(p, false)]);
+        let stopped = axis.scroll(&h);
+        events(&mut h, [Event::PointerMoved(p + axis.movement())]);
+        assert_eq!(axis.scroll(&h), stopped, "{axis:?}: returning with the button up must not scroll");
+    }
+}
+
+#[test]
+fn the_wayland_fallback_does_not_leave_either_scrollbar_captured_after_a_lost_release() {
+    for axis in [Axis::Vertical, Axis::Horizontal] {
+        let (mut h, p) = dragging_with_policy(axis, true);
+        // egui-winit can discard MouseInput after CursorLeft clears its pointer position.
+        events(&mut h, [Event::PointerGone]);
+        let stopped = axis.scroll(&h);
+        events(&mut h, [Event::PointerMoved(p + axis.movement())]);
+        events(&mut h, [Event::PointerMoved(p + axis.movement() * 2.0)]);
+        assert!(!h.ctx.input(|i| i.pointer.primary_down()), "the app releases the owned gesture even when the bridge missed its release");
+        assert_eq!(axis.scroll(&h), stopped, "{axis:?}: a missing outside release must not leave the thumb captured");
+    }
+}
+
+#[test]
+fn focus_loss_cancels_capture_even_when_the_window_does_not_paint() {
+    for axis in [Axis::Vertical, Axis::Horizontal] {
+        let (mut h, p) = dragging(axis);
+        let ctx = h.ctx.clone();
+        let hidden = egui::RawInput { focused: false, events: vec![Event::WindowFocused(false)], ..Default::default() };
+        assert!(ctx.dragged_id().is_some());
+        // eframe's hidden tick updates raw.focused without interpreting the UI events.
+        let _ = ctx.run_logic(&hidden, |ctx| h.state_mut().logic(ctx));
+        assert!(ctx.dragged_id().is_none(), "{axis:?}: the logic-only tick must release capture immediately");
+        let stopped = axis.scroll(&h);
+        // eframe retains the unconsumed events for the next full pass.
+        h.input_mut().events.extend(hidden.events);
+        events(&mut h, [Event::WindowFocused(true), Event::PointerMoved(p + axis.movement())]);
+        assert_eq!(axis.scroll(&h), stopped, "{axis:?}: logic-only focus loss must release capture");
+    }
+}
+
+#[test]
+fn hosts_with_pointer_capture_keep_a_held_drag_across_window_exit() {
+    for axis in [Axis::Vertical, Axis::Horizontal] {
+        let (mut h, p) = dragging(axis);
+        let before = axis.scroll(&h);
+        events(&mut h, [Event::PointerGone]);
+        events(&mut h, [Event::PointerMoved(p + axis.movement())]);
+        events(&mut h, [Event::PointerMoved(p + axis.movement() * 2.0)]);
+        assert!(axis.scroll(&h) > before, "{axis:?}: a held drag must survive window exit on capture-capable hosts");
+        events(&mut h, [button(p + axis.movement() * 2.0, false)]);
+    }
+}
+
+#[test]
+fn focus_loss_also_cancels_a_press_before_it_becomes_a_drag() {
+    for axis in [Axis::Vertical, Axis::Horizontal] {
+        let mut h = crate::support::blank_harness(4);
+        let p = axis.start(&h);
+        events(&mut h, [Event::PointerMoved(p), button(p, true)]);
+        events(&mut h, [Event::WindowFocused(false)]);
+        events(&mut h, [Event::WindowFocused(true), Event::PointerMoved(p + axis.movement())]);
+        assert_eq!(axis.scroll(&h), 0.0, "{axis:?}: an interrupted press must not start dragging later");
+    }
+}
+
+#[test]
+fn a_new_press_after_pointer_loss_in_the_same_frame_still_starts_a_drag() {
+    for axis in [Axis::Vertical, Axis::Horizontal] {
+        let mut h = crate::support::blank_harness(4);
+        h.state_mut().services.scrollbar_release_on_pointer_gone = true;
+        let p = axis.start(&h);
+        events(&mut h, [Event::PointerGone, Event::PointerMoved(p), button(p, true)]);
+        events(&mut h, [Event::PointerMoved(p + axis.movement())]);
+        assert!(axis.scroll(&h) > 0.0, "{axis:?}: an earlier pointer exit must not cancel a new press");
+        events(&mut h, [button(p + axis.movement(), false)]);
+    }
+}
+
+fn interrupted_capture_cannot_steal_a_new_press(interruption: &[Event]) {
+    for axis in [Axis::Vertical, Axis::Horizontal] {
+        let (mut h, p) = dragging_with_policy(axis, true);
+        let next_press = axis.outside_track(p);
+        let mut batch = interruption.to_vec();
+        batch.extend([Event::PointerMoved(next_press), button(next_press, true)]);
+        let ctx = h.ctx.clone();
+        let mut raw = egui::RawInput { events: batch, ..Default::default() };
+        h.state_mut().raw_input_hook(&ctx, &mut raw);
+        events(&mut h, raw.events);
+        let stopped = axis.scroll(&h);
+        events(&mut h, [Event::PointerMoved(next_press + axis.movement())]);
+        assert_eq!(axis.scroll(&h), stopped, "{axis:?}: the old capture must not steal a new press after interruption");
+        assert_ne!(h.state().session.active().unwrap().selection.active, CellRef::new(0, 0), "{axis:?}: the new cell gesture must still work");
+    }
+}
+
+#[test]
+fn pointer_loss_ends_the_old_capture_before_a_new_press_in_the_same_frame() {
+    interrupted_capture_cannot_steal_a_new_press(&[Event::PointerGone]);
+}
+
+#[test]
+fn focus_loss_ends_the_old_capture_before_a_new_press_in_the_same_frame() {
+    interrupted_capture_cannot_steal_a_new_press(&[Event::WindowFocused(false), Event::WindowFocused(true)]);
+}
+
+#[test]
+fn a_focused_logic_tick_does_not_replay_old_interruption_events() {
+    for interruption in [vec![Event::PointerGone], vec![Event::WindowFocused(false), Event::WindowFocused(true)]] {
+        for axis in [Axis::Vertical, Axis::Horizontal] {
+            let mut h = crate::support::blank_harness(4);
+            h.state_mut().services.scrollbar_release_on_pointer_gone = true;
+            let p = axis.start(&h);
+            h.input_mut().events.extend(interruption.iter().cloned());
+            h.input_mut().events.extend([Event::PointerMoved(p), button(p, true)]);
+            h.run_steps(1);
+            let ctx = h.ctx.clone();
+            let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| h.state_mut().logic(ctx));
+            events(&mut h, [Event::PointerMoved(p + axis.movement())]);
+            assert!(axis.scroll(&h) > 0.0, "{axis:?}: stale events in a focused hidden tick must not cancel a valid held press");
+        }
+    }
+}
+
+fn child_harness(release_on_pointer_gone: bool) -> (Harness, std::sync::Arc<std::sync::Mutex<Vec<Event>>>) {
+    let queued = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
+    let input = queued.clone();
+    egui::Context::set_immediate_viewport_renderer(move |ctx, viewport| {
+        let mut raw = egui::RawInput {
+            viewport_id: viewport.ids.this,
+            screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0))),
+            events: std::mem::take(&mut *input.lock().unwrap()),
+            ..Default::default()
+        };
+        raw.viewports.entry(viewport.ids.this).or_default().parent = Some(viewport.ids.parent);
+        let mut draw = viewport.viewport_ui_cb;
+        // As in eframe's immediate renderer, there is no App::raw_input_hook here.
+        let mut out = ctx.run_ui(raw, |ui| draw(ui));
+        out.textures_delta.clear(); // no renderer in this input test
+    });
+    let mut app = crate::common::app(crate::support::blank_session());
+    app.services.scrollbar_release_on_pointer_gone = release_on_pointer_gone;
+    let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
+        |ui, app: &mut gridcraft_ui_egui::SheetApp| {
+            let ctx = ui.ctx().clone();
+            app.logic(&ctx);
+            ctx.show_viewport_immediate(egui::ViewportId::from_hash_of("scrollbar child"), Default::default(), |child, _| app.ui(child));
+        },
+        app,
+    );
+    h.ctx.set_embed_viewports(false);
+    h.run_steps(4);
+    (h, queued)
+}
+
+fn child_events(h: &mut Harness, queued: &std::sync::Mutex<Vec<Event>>, events: impl IntoIterator<Item = Event>) {
+    queued.lock().unwrap().extend(events);
+    h.run_steps(2);
+}
+
+#[test]
+fn immediate_child_windows_keep_the_new_gesture_after_scrollbar_interruption() {
+    for interruption in [vec![Event::PointerGone], vec![Event::WindowFocused(false), Event::WindowFocused(true)]] {
+        for axis in [Axis::Vertical, Axis::Horizontal] {
+            let (mut h, queued) = child_harness(true);
+            let start = axis.start(&h);
+            child_events(&mut h, &queued, [Event::PointerMoved(start), button(start, true)]);
+            let p = start + axis.movement();
+            child_events(&mut h, &queued, [Event::PointerMoved(p)]);
+            assert!(axis.scroll(&h) > 0.0, "{axis:?}: the child scrollbar started dragging");
+            let next_press = axis.outside_track(p);
+            let mut batch = interruption.clone();
+            batch.extend([Event::PointerMoved(next_press), button(next_press, true)]);
+            child_events(&mut h, &queued, batch);
+            let stopped = axis.scroll(&h);
+            child_events(&mut h, &queued, [Event::PointerMoved(next_press + axis.movement())]);
+            assert_eq!(axis.scroll(&h), stopped, "{axis:?}: the child scrollbar stopped");
+            assert_ne!(
+                h.state().session.active().unwrap().selection.active,
+                CellRef::new(0, 0),
+                "{axis:?}: the child's new cell gesture still works"
+            );
+        }
+    }
+}
+
+#[test]
+fn immediate_child_windows_preserve_held_drags_without_the_wayland_fallback() {
+    for axis in [Axis::Vertical, Axis::Horizontal] {
+        let (mut h, queued) = child_harness(false);
+        let start = axis.start(&h);
+        child_events(&mut h, &queued, [Event::PointerMoved(start), button(start, true)]);
+        let p = start + axis.movement();
+        child_events(&mut h, &queued, [Event::PointerMoved(p)]);
+        let before = axis.scroll(&h);
+        child_events(&mut h, &queued, [Event::PointerGone]);
+        child_events(&mut h, &queued, [Event::PointerMoved(p + axis.movement())]);
+        child_events(&mut h, &queued, [Event::PointerMoved(p + axis.movement() * 2.0)]);
+        assert!(axis.scroll(&h) > before, "{axis:?}: a held child drag must survive window exit on capture-capable hosts");
+    }
+}
+
+#[test]
+fn a_child_release_and_repress_on_the_same_thumb_starts_a_fresh_capture() {
+    for axis in [Axis::Vertical, Axis::Horizontal] {
+        let (mut h, queued) = child_harness(true);
+        let p = axis.start(&h);
+        child_events(&mut h, &queued, [Event::PointerMoved(p), button(p, true)]);
+        child_events(&mut h, &queued, [button(p, false), Event::PointerGone, Event::PointerMoved(p), button(p, true)]);
+        child_events(&mut h, &queued, [Event::PointerMoved(p + axis.movement())]);
+        assert!(axis.scroll(&h) > 0.0, "{axis:?}: a delivered release must give the next child press a fresh capture");
+    }
+}
+
+#[test]
+fn a_child_pending_thumb_press_cannot_steal_a_new_gesture_after_interruption() {
+    for interruption in [vec![Event::PointerGone], vec![Event::WindowFocused(false), Event::WindowFocused(true)]] {
+        for axis in [Axis::Vertical, Axis::Horizontal] {
+            let (mut h, queued) = child_harness(true);
+            let p = axis.start(&h);
+            child_events(&mut h, &queued, [Event::PointerMoved(p), button(p, true)]);
+            let next_press = axis.outside_track(p) + axis.movement() * 2.0;
+            let mut batch = interruption.clone();
+            batch.extend([Event::PointerMoved(next_press), button(next_press, true)]);
+            child_events(&mut h, &queued, batch);
+            child_events(&mut h, &queued, [Event::PointerMoved(next_press + axis.movement())]);
+            assert_eq!(axis.scroll(&h), 0.0, "{axis:?}: the interrupted pending thumb must stay stopped");
+            assert_ne!(h.state().session.active().unwrap().selection.active, CellRef::new(0, 0), "{axis:?}: the new child gesture must work");
+        }
+    }
+}
+
+fn track_end(axis: Axis, h: &Harness) -> Pos2 {
+    match axis {
+        Axis::Vertical => pos2(h.state().grid.rect.unwrap().right() + 7.0, h.state().grid.cells_rect.unwrap().bottom() - 12.0),
+        Axis::Horizontal => {
+            let track = h.state().grid.hscroll.unwrap();
+            pos2(track.right() - 6.0, track.center().y)
+        }
+    }
+}
+
+#[test]
+fn cancelling_a_child_pending_track_press_does_not_page() {
+    for interruption in [Event::PointerGone, Event::WindowFocused(false)] {
+        for axis in [Axis::Vertical, Axis::Horizontal] {
+            let (mut h, queued) = child_harness(true);
+            let p = track_end(axis, &h);
+            child_events(&mut h, &queued, [Event::PointerMoved(p), button(p, true)]);
+            queued.lock().unwrap().push(interruption.clone());
+            h.run_steps(1);
+            assert_eq!(axis.scroll(&h), 0.0, "{axis:?}: cancelling must not turn the old press into a track click");
+            h.ctx.input_for(egui::ViewportId::from_hash_of("scrollbar child"), |input| {
+                assert!(!input.pointer.primary_clicked(), "cancellation must not generate a click anywhere in the app");
+                assert_eq!(input.pointer.delta(), Vec2::ZERO, "cancellation must not expose pointer movement");
+            });
+        }
+    }
+}
+
+#[test]
+fn a_new_child_click_after_cancellation_is_not_a_double_click() {
+    for axis in [Axis::Vertical, Axis::Horizontal] {
+        let (mut h, queued) = child_harness(true);
+        let p = axis.start(&h);
+        child_events(&mut h, &queued, [Event::PointerMoved(p), button(p, true)]);
+        let next_press = p + axis.movement().normalized() * 2.0;
+        queued.lock().unwrap().extend([
+            Event::WindowFocused(false),
+            Event::WindowFocused(true),
+            Event::PointerMoved(next_press),
+            button(next_press, true),
+            button(next_press, false),
+        ]);
+        h.run_steps(1);
+        h.ctx.input_for(egui::ViewportId::from_hash_of("scrollbar child"), |input| {
+            assert!(input.pointer.primary_clicked(), "the new click must work");
+            assert!(!input.pointer.button_double_clicked(PointerButton::Primary), "the cancelled press must not count towards a double-click");
+        });
+    }
+}
+
+#[test]
+fn ordinary_child_track_clicks_page_when_released_in_a_later_frame() {
+    for fallback in [false, true] {
+        for axis in [Axis::Vertical, Axis::Horizontal] {
+            let (mut h, queued) = child_harness(fallback);
+            let p = track_end(axis, &h);
+            child_events(&mut h, &queued, [Event::PointerMoved(p), button(p, true)]);
+            assert_eq!(axis.scroll(&h), 0.0, "the track waits for the release");
+            child_events(&mut h, &queued, [button(p, false)]);
+            assert!(axis.scroll(&h) > 0.0, "{axis:?}: an ordinary child track click must page");
+        }
+    }
+}
+
+#[test]
+fn a_child_track_click_completed_before_pointer_exit_still_pages() {
+    for axis in [Axis::Vertical, Axis::Horizontal] {
+        let (mut h, queued) = child_harness(true);
+        let p = track_end(axis, &h);
+        child_events(&mut h, &queued, [Event::PointerMoved(p), button(p, true)]);
+        child_events(&mut h, &queued, [button(p, false), Event::PointerGone]);
+        assert!(axis.scroll(&h) > 0.0, "{axis:?}: a release before exit is a completed click");
+    }
+}
+
+#[test]
+fn a_root_pending_thumb_press_does_not_steal_a_later_cell_click() {
+    for interruption in [vec![Event::PointerGone], vec![Event::WindowFocused(false), Event::WindowFocused(true)]] {
+        for axis in [Axis::Vertical, Axis::Horizontal] {
+            let mut h = crate::support::blank_harness(4);
+            h.state_mut().services.scrollbar_release_on_pointer_gone = true;
+            let p = axis.start(&h);
+            events(&mut h, [Event::PointerMoved(p), button(p, true)]);
+            let next_press = axis.outside_track(p) + axis.movement() * 2.0;
+            let mut batch = interruption.clone();
+            batch.extend([Event::PointerMoved(next_press), button(next_press, true), button(next_press, false)]);
+            events(&mut h, batch);
+            assert_eq!(axis.scroll(&h), 0.0, "{axis:?}: the old thumb must stay stopped");
+            assert_ne!(h.state().session.active().unwrap().selection.active, CellRef::new(0, 0), "{axis:?}: the new root click must select its cell");
+        }
+    }
+}
+
+#[test]
+fn a_hidden_root_focus_loss_does_not_let_a_pending_press_steal_the_next_click() {
+    for axis in [Axis::Vertical, Axis::Horizontal] {
+        let mut h = crate::support::blank_harness(4);
+        let p = axis.start(&h);
+        events(&mut h, [Event::PointerMoved(p), button(p, true)]);
+        let ctx = h.ctx.clone();
+        let hidden = egui::RawInput { focused: false, events: vec![Event::WindowFocused(false)], ..Default::default() };
+        let _ = ctx.run_logic(&hidden, |ctx| h.state_mut().logic(ctx));
+        h.input_mut().events.extend(hidden.events);
+        let next_press = axis.outside_track(p) + axis.movement() * 2.0;
+        events(&mut h, [Event::WindowFocused(true), Event::PointerMoved(next_press), button(next_press, true), button(next_press, false)]);
+        assert_eq!(axis.scroll(&h), 0.0, "{axis:?}: hidden cancellation must not page the old thumb");
+        assert_ne!(
+            h.state().session.active().unwrap().selection.active,
+            CellRef::new(0, 0),
+            "{axis:?}: the next root click must work after a hidden tick"
+        );
+    }
+}
+
+#[test]
+fn a_real_child_track_click_after_interruption_in_the_same_batch_still_pages() {
+    for axis in [Axis::Vertical, Axis::Horizontal] {
+        let (mut h, queued) = child_harness(true);
+        let p = axis.start(&h);
+        child_events(&mut h, &queued, [Event::PointerMoved(p), button(p, true)]);
+        let next_press = track_end(axis, &h);
+        child_events(
+            &mut h,
+            &queued,
+            [
+                Event::WindowFocused(false),
+                Event::WindowFocused(true),
+                Event::PointerMoved(next_press),
+                button(next_press, true),
+                button(next_press, false),
+            ],
+        );
+        assert!(axis.scroll(&h) > 0.0, "{axis:?}: cancellation must not swallow a real later track click");
+    }
+}
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -1,6 +1,6 @@
 # UI parity
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (worksheet scrollbar capture cleanup, #2748) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
 
 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md).
 **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured
@@ -71,6 +71,7 @@ this logic, with one undo step.
 | Context menus on row/column headers, sheet tabs, charts, shapes | headers and tabs done; objects partial |
 | Quick Analysis (Ctrl+Q) | missing |
 | Smooth scrolling, scrollbars | done; vertical scrollbar added 10-09 (#73); horizontal scrollbar in the tab bar (60/40 split, drag, track click pages) 10-13 (#176, #840); dragging the handle at the scrollbar's left edge gives the tabs more or less of the bar; each workbook keeps its share while it is open (not saved in the file) |
+| Worksheet scrollbar capture | Both axes end a drag on observed button-up or focus loss, including when the window only runs logic. Native Wayland also cancels on `PointerGone`: the pinned bridge can drop an outside release and keep reporting the button down. This fallback can end a held drag at the window edge on Wayland; Windows, macOS, X11 and web keep capture across pointer exit (#2748). Ubuntu/Wayland runtime behavior is unverified. |
 | Text overflow into empty neighbours, wrap, rotation, shrink to fit | done |
 
 ## Keyboard
@@ -187,6 +188,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-27 | minor | Worksheet scrollbar capture cleanup and the native Wayland pointer-exit fallback (#2748) |
 | 2026-10-25 | minor | AutoSum (Alt+=, Home › AutoSum) over a selected row puts its total to the right of the row, or in its last cell when that is empty, as in Excel; it wrote a one-cell total under each cell. A selection's empty last column takes each row's total and its empty last row each column's, with the block's total in the corner when both are empty |
 | 2026-10-25 | minor | Paste Name lays out only the names in view, so a workbook with thousands of names opens it at once; the arrow keys still scroll the chosen name into view. Go To matches a `Sheet2!Name` reference and hides a workbook name behind the active sheet's own name in any case and script (`Größe` = `GRÖSSE`), as formulas do. The two Page Layout view rows are one; the 0.7 in side margin used when Align with Page Margins is off is marked unverified |
 | 2026-10-24 | minor | Go To lists the named ranges on every sheet, as Excel's does ("The Go to popup window shows named ranges on every worksheet in your workbook"): a name scoped to another sheet appears as `Sheet2!Name`, and hidden names, constants and formulas are left out. It keeps the references entered (newest first, ten at most), and a double-click on an entry goes there. The Reference box and `edit.goTo` take several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30,H21:H29`) and select them all; `edit.goTo {list: true}` returns the list |
