From 17b8bfecded7ef897e365ef41c6bc9fec053fe73 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 25 Jul 2026 20:02:20 -0700 Subject: [PATCH] fix(dashboard): stop small mobile board swipes from jumping several columns Multi-column fling reach came from release velocity alone, so a quick short thumb flick (~30px, several px/ms) bought 2-3 extra columns and the board flew past the intended column. Extra pages now also have to be earned with travel. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/mobile-board-swipe-travel-gate.md | 7 +++ .../__tests__/useColumnScrollSnap.test.ts | 33 ++++++++++++ .../app/hooks/useColumnScrollSnap.ts | 51 +++++++++++++++++-- 3 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 .changeset/mobile-board-swipe-travel-gate.md diff --git a/.changeset/mobile-board-swipe-travel-gate.md b/.changeset/mobile-board-swipe-travel-gate.md new file mode 100644 index 0000000000..61f547d208 --- /dev/null +++ b/.changeset/mobile-board-swipe-travel-gate.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Small mobile board swipes no longer jump several columns at once. +category: fix +dev: `resolvePageCount` in `useColumnScrollSnap` now gates each extra fling page on net gesture travel (max of board scroll delta and horizontal finger travel) against viewport width, not release velocity alone. diff --git a/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts b/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts index f0b4fb5956..00a9f21829 100644 --- a/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts @@ -154,6 +154,39 @@ describe("resolvePageCount", () => { expect(resolvePageCount(-3.4)).toBe(3); expect(resolvePageCount(Number.NaN)).toBe(1); }); + + /* + FNXC:BoardNavigation 2026-07-25-09:40: + Reported symptom: a small swipe jumped several columns because a quick short flick reads fast. + Extra columns now require travel as well as speed. + */ + describe("travel gate", () => { + const viewportWidth = 390; + + it("keeps a fast but short flick to a single column", () => { + expect(resolvePageCount(3.4, { travelPx: 30, viewportWidth })).toBe(1); + expect(resolvePageCount(40, { travelPx: 60, viewportWidth })).toBe(1); + }); + + it("still allows multi-column reach when the swipe actually travelled", () => { + expect(resolvePageCount(1.7, { travelPx: viewportWidth, viewportWidth })).toBe(2); + expect(resolvePageCount(3.4, { travelPx: viewportWidth * 2, viewportWidth })).toBe(3); + }); + + it("never lets travel alone buy columns a slow gesture did not earn", () => { + expect(resolvePageCount(0.4, { travelPx: viewportWidth * 3, viewportWidth })).toBe(1); + }); + + it("ignores the gate when no usable viewport width is available", () => { + expect(resolvePageCount(3.4, { travelPx: 10, viewportWidth: 0 })).toBe(3); + expect(resolvePageCount(3.4, { travelPx: 10, viewportWidth: Number.NaN })).toBe(3); + }); + + it("treats travel as a magnitude and tolerates non-finite travel", () => { + expect(resolvePageCount(1.7, { travelPx: -viewportWidth, viewportWidth })).toBe(2); + expect(resolvePageCount(1.7, { travelPx: Number.NaN, viewportWidth })).toBe(1); + }); + }); }); describe("resolvePageAnimationMs", () => { diff --git a/packages/dashboard/app/hooks/useColumnScrollSnap.ts b/packages/dashboard/app/hooks/useColumnScrollSnap.ts index 7c7fdc949e..6467c5cb93 100644 --- a/packages/dashboard/app/hooks/useColumnScrollSnap.ts +++ b/packages/dashboard/app/hooks/useColumnScrollSnap.ts @@ -61,6 +61,15 @@ const VELOCITY_SAMPLE_WINDOW_MS = 120; const FLING_VELOCITY_PER_EXTRA_PAGE = 1.6; /** Ceiling so a hard flick cannot fly across the whole board. */ const MAX_PAGES_PER_SWIPE = 3; +/* +FNXC:BoardNavigation 2026-07-25-09:40: +A SHORT swipe must never cross more than one column, however fast the flick was. Velocity alone +over-reached: a quick thumb flick of ~30px reads as multiple px/ms and paged two or three columns, +so the board jumped past what the user aimed at. Each extra column now also has to be earned with +travel — the gesture must move at least this fraction of the viewport width per extra page — so +reach stays proportional to the swipe the user actually made. +*/ +const TRAVEL_FRACTION_PER_EXTRA_PAGE = 0.6; /** Below this the animation is pointless — jump. */ const MIN_ANIMATED_DISTANCE_PX = 2; @@ -91,12 +100,31 @@ function easeOutCubic(progress: number): number { * * A deliberate slow swipe pages exactly one column; faster releases buy extra columns so the * hook's owned animation keeps the reach a native fling used to provide. + * + * FNXC:BoardNavigation 2026-07-25-09:40: + * Extra columns must be earned by BOTH speed and distance. `travelPx` (net gesture travel — the + * larger of board scroll delta and horizontal finger travel) against `viewportWidth` caps the + * count, so a fast but short flick pages exactly one column instead of jumping across the board. + * The travel gate is skipped when the caller cannot supply a usable viewport width. */ -export function resolvePageCount(velocityPxPerMs: number): number { +export function resolvePageCount( + velocityPxPerMs: number, + travel?: { travelPx: number; viewportWidth: number }, +): number { const speed = Math.abs(velocityPxPerMs); if (!Number.isFinite(speed) || speed <= 0) return 1; - const extra = Math.floor(speed / FLING_VELOCITY_PER_EXTRA_PAGE); - return Math.min(1 + extra, MAX_PAGES_PER_SWIPE); + const extraFromVelocity = Math.floor(speed / FLING_VELOCITY_PER_EXTRA_PAGE); + + let extra = extraFromVelocity; + if (travel && Number.isFinite(travel.viewportWidth) && travel.viewportWidth > 0) { + const travelPx = Math.abs(travel.travelPx); + const extraFromTravel = Number.isFinite(travelPx) + ? Math.floor(travelPx / (travel.viewportWidth * TRAVEL_FRACTION_PER_EXTRA_PAGE)) + : 0; + extra = Math.min(extraFromVelocity, extraFromTravel); + } + + return Math.min(1 + Math.max(0, extra), MAX_PAGES_PER_SWIPE); } /** Duration for a `pageCount`-column hop. */ @@ -714,7 +742,22 @@ export function useColumnScrollSnap( const nearestIndex = nearestColumnIndex(scroller, columns); // A gesture begun mid-transit has no trustworthy origin: page from where it actually is. const originIndex = gestureStartCentered ? gestureStartColumnIndex : nearestIndex; - const pageCount = resolvePageCount(resolveReleaseVelocity()); + /* + FNXC:BoardNavigation 2026-07-25-09:40: + Net gesture travel gates multi-column reach. Take the larger of the board's own scroll delta + and the finger's horizontal travel: on iOS the native pan owns the touch stream (scroll delta + is the faithful signal), while a finger that dragged against a rubber-banding edge shows + travel only in the client coordinates. + */ + const scrollTravel = Math.abs(scroller.scrollLeft - gestureStartScrollLeft); + const fingerTravel = + gestureStartClientX !== null && lastClientX !== null + ? Math.abs(gestureStartClientX - lastClientX) + : 0; + const pageCount = resolvePageCount(resolveReleaseVelocity(), { + travelPx: Math.max(scrollTravel, fingerTravel), + viewportWidth, + }); const targetIndex = resolveFlingTargetIndex({ columnCount: columns.length, originIndex,