diff --git a/docs/architecture/05-transform-engine.md b/docs/architecture/05-transform-engine.md
index 5f42042e..d428a0c3 100644
--- a/docs/architecture/05-transform-engine.md
+++ b/docs/architecture/05-transform-engine.md
@@ -240,6 +240,15 @@ DWM magnifies with nearest neighbour unless something calls
resulting shift per level. While smooth, the applied level snaps to the nearest level predicting
under 1 px (never backwards in a ramp; held once the zoom settles). Measured standalone at
3840x2160: 10-25x jitter 11 px -> 0.7 px p95, worst jump 42 px -> 2 px; 2-10x 4.7 px -> 0.8 px.
+- The ladder's cost is uneven speed: above ~6x clean levels are ~0.9 % apart while a tick moves ~3 %,
+ so single ticks run 15-40 % fast or slow (measured 2026-10-10: tick-to-tick rate change 8-21 %
+ median above 6x, 1-5 % with the ladder off or nearest). Above 12x, where the tolerance relaxes, a
+ slow held zoom shows small image jumps (predicted 3.6-4.2 px p95 at 12-20x). Capped, rate-keeping
+ and stricter snaps were all simulated against the trace and the ladder's own model: each traded
+ the shake for uneven speed or lurches, so the ladder stays as it is.
+- No release glide with the high resolution cursor (#427): the ladder could only cut a glide short
+ or let its slow tail cross rounding steps (which shook the image and showed it doubled, closed
+ #426), so `zoomEaseOutMs` is not applied while `txSamplingMode=1`; the zoom stops on release.
- Nearest while the level moves and smooth at rest was tried: steady, but the switch from pixel to
smooth is plainly visible, so it was rejected (2026-10-07). The older "swap shifted the image
1-2 px" verdict predates the working setter and is void.
diff --git a/docs/architecture/09-settings-ui.md b/docs/architecture/09-settings-ui.md
index df402f8b..170fee77 100644
--- a/docs/architecture/09-settings-ui.md
+++ b/docs/architecture/09-settings-ui.md
@@ -73,7 +73,10 @@ the reply invalid and the page waits forever.
## The schema-driven app
-The page is generated from `groups` in `ui/src/settings-schema.js`. Six groups: Hotkeys, Zoom,
+The page is generated from `groups` in `ui/src/settings-schema.js`. A row may carry `showIf` (hidden
+unless another setting has a value), `offIf` (kept in place but dimmed and inert while another
+setting has a value: Release glide while the high resolution cursor is on) and `tag` (a small label
+after the name: High resolution cursor is tagged Experimental). Six groups: Hotkeys, Zoom,
View, then below a divider Preferences (with the Screen light card), Tray menu and About. Each group has a label, an
icon, a banner description and cards of rows; each row names its ini key, type, label, description
and default. `Settings.svelte` is the shell (title bar, sidebar, banner, save capsule, search), and
diff --git a/src/config.h b/src/config.h
index ce86e946..c64c987f 100644
--- a/src/config.h
+++ b/src/config.h
@@ -85,6 +85,7 @@ struct Config {
// Release ease-out (2026-08-28, hot): the applied zoom rate glides to a stop over roughly
// 3x this time constant instead of freezing the instant the button lifts (the square-wave
// stop read as harsh). ~45ms tau = ~150ms felt glide. 0 = off (the old dead stop).
+ // Not applied with the high resolution cursor (txSamplingMode=1), which stops on release (#427).
int zoomEaseOutMs = 45;
// Present sync while zoomed (render engine): 1 = vsync (Present sync-interval 1, locked to
// the display refresh); 0 = no vsync (Present 0), with the loop paced by the timer instead.
diff --git a/src/main.cpp b/src/main.cpp
index 9ec8e996..82a157d4 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1293,7 +1293,10 @@ static void RunTick(TickState& t) {
// root-caused elsewhere - issue #148 - so the user's configured speed applies everywhere.)
t.zoom.setProfile(t.cfg.zoomInSpeed, t.cfg.zoomOutSpeed, t.cfg.smoothZoom != 0,
t.cfg.smoothZoomAccel, t.cfg.smoothZoomRamp);
- t.zoom.setEaseOut(t.cfg.zoomEaseOutMs / 1000.0);
+ // No release glide with the high resolution cursor (#427): its ladder can only cut a glide short
+ // (EaseOutShouldStop) or let the slow tail cross DWM's rounding steps, which shook the image and
+ // showed it doubled (field 2026-10-10, closed #426). The zoom stops on release instead.
+ t.zoom.setEaseOut(t.cfg.txSamplingMode == 1 ? 0.0 : t.cfg.zoomEaseOutMs / 1000.0);
// Quick-zoom trigger. Modifier mode (quickZoomHotkeyMode==0): hold the configured modifier
// (Ctrl/Alt/Shift; "None" = off) and tap a zoom key. While the modifier is held it toggles quick
// zoom (below) instead of hold-zooming, so suppress the hold-zoom direction (the toggle snaps the
diff --git a/src/version.h b/src/version.h
index 5378472d..50963081 100644
--- a/src/version.h
+++ b/src/version.h
@@ -3,8 +3,8 @@
#pragma once
#define WIND_VER_MAJOR 0
-#define WIND_VER_MINOR 24
-#define WIND_VER_PATCH 20
+#define WIND_VER_MINOR 25
+#define WIND_VER_PATCH 0
// String form for logs/snapshot/UI. Keep in sync with the numeric parts above.
-#define WIND_VERSION_STR "0.24.20"
+#define WIND_VERSION_STR "0.25.0"
diff --git a/ui/src/Settings.svelte b/ui/src/Settings.svelte
index 177a903d..3b92467b 100644
--- a/ui/src/Settings.svelte
+++ b/ui/src/Settings.svelte
@@ -215,7 +215,7 @@
function clearSearch() { query = ''; const i = searchInput(); if (i) i.value = ''; }
// A result's control is the real one; a row hidden on its page by showIf (it only applies under another
// setting) shows dimmed, like the rest of the app shows a gated control.
- const isDisabled = (r) => !visible(r);
+ const isDisabled = (r) => !visible(r) || isOff(r);
// Enter or Down in the search box moves into the best result's control (Tab then walks the rest).
function focusFirstResult() {
const c = main && main.querySelector('.res .hit button, .res .hit input, .res .hit select, .res .hit [tabindex="0"]');
@@ -244,6 +244,8 @@
else if (a === 'exportDiagnostics') exportDiagnostics();
else if (a === 'quitWind') requestQuit();
}
+ // offIf: the row stays in place but dimmed and inert while another setting has that value (#427).
+ const isOff = (r) => !!r.offIf && String(values[r.offIf.key]) === String(r.offIf.eq);
const visible = (r) => !r.showIf || (r.showIf.ne !== undefined ? String(values[r.showIf.key]) !== String(r.showIf.ne) : String(values[r.showIf.key]) === String(r.showIf.eq));
// Advanced rows show on their page only while the global switch is on; search results show them always.
const advOn = $derived(Number(values.showAdvanced) === 1);
@@ -280,7 +282,7 @@
{#if rows.length}