From c61336d24cd6c8ac47cecdbcccfb02071737f177 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 22 Jul 2026 17:51:54 -0700 Subject: [PATCH] fix(dashboard): stop vertical card scrolls paging the board and let re-drags override pending snaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two mobile board gesture fixes: (1) finger travel only counts as horizontal pan intent when it dominates the vertical axis, so scrolling a column's card list with incidental diagonal drift no longer swipes to another column; (2) the commit-one-column paging clamp applies only to gestures begun at rest centered on a column — a tap-to-stop mid-momentum followed by a drag settles on the nearest column at the drag's landing point instead of being forced a column past it by the interrupted scroll's origin. Co-Authored-By: Claude Fable 5 --- .../mobile-board-pointercancel-settle.md | 4 +- .../__tests__/useColumnScrollSnap.test.ts | 66 ++++++++++++- .../app/hooks/useColumnScrollSnap.ts | 93 ++++++++++++++----- 3 files changed, 136 insertions(+), 27 deletions(-) diff --git a/.changeset/mobile-board-pointercancel-settle.md b/.changeset/mobile-board-pointercancel-settle.md index 2f3300e7e4..3da933ae8f 100644 --- a/.changeset/mobile-board-pointercancel-settle.md +++ b/.changeset/mobile-board-pointercancel-settle.md @@ -2,6 +2,6 @@ "@runfusion/fusion": patch --- -summary: Fix mobile board drags resting between columns, edge-column snap-back glitches, and fling overshoot past the mostly-visible column. +summary: Fix mobile board column snapping — mid-screen rests, edge glitches, fling overshoot, false swipes from vertical card scrolls, and re-drags after tap-to-stop. category: fix -dev: useColumnScrollSnap now ignores pointercancel while the touch stream is still live (native scroll takeover), and settle targets the nearest column clamped to one column of progress from the gesture origin (resolveSettleTargetIndex) instead of always paging past it. +dev: useColumnScrollSnap now ignores pointercancel while the touch stream is live, settles to nearest-with-min-progress (resolveSettleTargetIndex), requires horizontal-dominant finger travel for pan intent, and lets a gesture begun mid-transit settle to plain nearest so a corrective drag wins. diff --git a/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts b/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts index 468cc6665a..420b473050 100644 --- a/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts @@ -70,9 +70,14 @@ function createScroller(columnCount = 3, initialScrollLeft = 0): HTMLElement { return scroller; } -function dispatchPointerEvent(scroller: HTMLElement, type: string, clientX: number): void { +function dispatchPointerEvent( + scroller: HTMLElement, + type: string, + clientX: number, + clientY = 0, +): void { scroller.dispatchEvent( - new PointerEvent(type, { clientX, pointerId: 1, isPrimary: true, bubbles: true, cancelable: true }), + new PointerEvent(type, { clientX, clientY, pointerId: 1, isPrimary: true, bubbles: true, cancelable: true }), ); } @@ -279,6 +284,63 @@ describe("useColumnScrollSnap", () => { expect(scroller.scrollLeft).toBe(COLUMN_WIDTH); }); + /* + FNXC:BoardNavigation 2026-07-22-21:40: + A vertical card-list scroll with incidental diagonal drift (dx ≥ 12px but dy dominant) must + not read as a horizontal swipe — it previously paged the board to the next column. + */ + it("does not page the board when a vertical card-list scroll drifts diagonally", () => { + const scroller = createScroller(3, 0); + renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => { + dispatchPointerEvent(scroller, "pointerdown", 200, 400); + // 15px of horizontal drift during 140px of vertical scrolling inside a column. + dispatchPointerEvent(scroller, "pointermove", 185, 260); + dispatchPointerEvent(scroller, "pointerup", 185, 260); + }); + settleAfterMomentum(); + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(scroller.scrollLeft).toBe(0); + }); + + /* + FNXC:BoardNavigation 2026-07-22-21:40: + Tap-to-stop during momentum, then drag: the new drag's landing point must win. The + commit-one-column clamp only applies to gestures that began centered at rest — from a + mid-transit origin it forced a page past the corrective drag. + */ + it("takes the new drag's landing point after a tap-to-stop mid-transit", () => { + const scroller = createScroller(3, 0); + renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => { + // Swipe right, coast mid-transit past column 1's center. + dispatchPointerEvent(scroller, "pointerdown", 200); + dispatchPointerEvent(scroller, "pointermove", 160); + scroller.scrollLeft = 30; + scroller.dispatchEvent(new Event("scroll")); + dispatchPointerEvent(scroller, "pointerup", 160); + scroller.scrollLeft = 130; + scroller.dispatchEvent(new Event("scroll")); + + // Tap to stop, then drag back left onto column 1's center. + dispatchPointerEvent(scroller, "pointerdown", 150); + dispatchPointerEvent(scroller, "pointermove", 180); + scroller.scrollLeft = 100; + scroller.dispatchEvent(new Event("scroll")); + dispatchPointerEvent(scroller, "pointerup", 180); + }); + settleAfterMomentum(); + + // Regression: the min-progress clamp previously forced column 0 (scrollLeft 0). + expect(scroller.scrollLeft).toBe(COLUMN_WIDTH); + expect(isColumnCentered(scroller, [...scroller.children] as HTMLElement[])).toBe(true); + }); + it("does not overshoot a fling that decelerates with the next column mostly on screen", () => { const scroller = createScroller(3, 0); renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); diff --git a/packages/dashboard/app/hooks/useColumnScrollSnap.ts b/packages/dashboard/app/hooks/useColumnScrollSnap.ts index e14001737a..5352e38d11 100644 --- a/packages/dashboard/app/hooks/useColumnScrollSnap.ts +++ b/packages/dashboard/app/hooks/useColumnScrollSnap.ts @@ -48,13 +48,13 @@ function addMediaChangeListener(query: MediaQueryList, listener: () => void): () return () => query.removeListener(listener); } -function getClientX(event: Event): number | null { +function getClientPoint(event: Event): { x: number; y: number } | null { if (typeof TouchEvent !== "undefined" && event instanceof TouchEvent) { const touch = event.touches[0] ?? event.changedTouches[0]; - return touch ? touch.clientX : null; + return touch ? { x: touch.clientX, y: touch.clientY } : null; } if ("clientX" in event && typeof (event as PointerEvent).clientX === "number") { - return (event as PointerEvent).clientX; + return { x: (event as PointerEvent).clientX, y: (event as PointerEvent).clientY }; } return null; } @@ -112,15 +112,23 @@ export function isColumnCentered( * Resolve pan direction from the full gesture (net deltas only). * Do NOT pass last micro-tick direction for settle — rubber-band flips it. * +1 = scroll right / next columns, -1 = scroll left / previous. + * + * FNXC:BoardNavigation 2026-07-22-21:40: + * Finger travel counts as horizontal pan intent only when it dominates the vertical axis — + * a vertical card-list scroll with incidental diagonal drift must not page the board. + * The board's own horizontal scrollDelta stays authoritative regardless of finger axis. */ export function resolvePanDirection(options: { scrollDelta: number; /** gestureStartClientX - endClientX: finger left → positive → next column */ clientDelta: number; + /** gestureStartClientY - endClientY: vertical finger travel for axis dominance. */ + clientDeltaY?: number; }): number { - const { scrollDelta, clientDelta } = options; + const { scrollDelta, clientDelta, clientDeltaY = 0 } = options; if (scrollDelta > CENTER_TOLERANCE_PX) return 1; if (scrollDelta < -CENTER_TOLERANCE_PX) return -1; + if (Math.abs(clientDelta) <= Math.abs(clientDeltaY)) return 0; if (clientDelta >= MIN_PAN_CLIENT_PX) return 1; if (clientDelta <= -MIN_PAN_CLIENT_PX) return -1; return 0; @@ -249,9 +257,20 @@ export function useColumnScrollSnap( let gestureStartScrollLeft = scroller.scrollLeft; /** Column the viewport rested on when the gesture began — the paging baseline. */ let gestureStartColumnIndex = 0; + /* + FNXC:BoardNavigation 2026-07-22-21:40: + The commit-one-column paging rule assumes the gesture began AT REST centered on its origin + column. A re-touch mid-transit (tap-to-stop during momentum, then drag) is not at rest: the + forced min-one-column progress from a mid-transit origin overrode the user's corrective drag + and paged past where they dragged. Such gestures settle on the plain nearest column instead — + the new drag's landing point always wins over the interrupted scroll. + */ + let gestureStartCentered = true; let lastScrollLeft = scroller.scrollLeft; let gestureStartClientX: number | null = null; let lastClientX: number | null = null; + let gestureStartClientY: number | null = null; + let lastClientY: number | null = null; /** Locked at finger-up / cancel — never updated by post-lift rubber-band ticks. */ let lockedDirection = 0; let sawHorizontalMovement = false; @@ -314,7 +333,11 @@ export function useColumnScrollSnap( gestureStartClientX !== null && lastClientX !== null ? gestureStartClientX - lastClientX : 0; - lockedDirection = resolvePanDirection({ scrollDelta, clientDelta }); + const clientDeltaY = + gestureStartClientY !== null && lastClientY !== null + ? gestureStartClientY - lastClientY + : 0; + lockedDirection = resolvePanDirection({ scrollDelta, clientDelta, clientDeltaY }); }; /* @@ -381,23 +404,31 @@ export function useColumnScrollSnap( gestureStartClientX !== null && lastClientX !== null ? gestureStartClientX - lastClientX : 0; + const clientDeltaY = + gestureStartClientY !== null && lastClientY !== null + ? gestureStartClientY - lastClientY + : 0; // Prefer direction locked at lift; recompute only if never locked. const direction = lockedDirection !== 0 ? lockedDirection - : resolvePanDirection({ scrollDelta, clientDelta }); + : resolvePanDirection({ scrollDelta, clientDelta, clientDeltaY }); + // FNXC:BoardNavigation 2026-07-22-21:40: finger travel implies pan only when horizontal dominates. const hadPanIntent = sawHorizontalMovement || Math.abs(scrollDelta) > CENTER_TOLERANCE_PX || - Math.abs(clientDelta) >= MIN_PAN_CLIENT_PX; + (Math.abs(clientDelta) >= MIN_PAN_CLIENT_PX && Math.abs(clientDelta) > Math.abs(clientDeltaY)); + const startedCentered = gestureStartCentered; interactionActive = false; sawHorizontalMovement = false; lockedDirection = 0; gestureStartClientX = null; lastClientX = null; + gestureStartClientY = null; + lastClientY = null; /* FNXC:BoardNavigation 2026-07-22-15:26: @@ -433,7 +464,14 @@ export function useColumnScrollSnap( return; } - const targetIndex = direction === 0 + /* + FNXC:BoardNavigation 2026-07-22-21:40: + Commit-one-column paging only applies to gestures that began at rest centered on their + origin column. A gesture begun mid-transit (tap-to-stop during momentum, then drag) + settles on the plain nearest column so the new drag's landing point wins over the + interrupted scroll's pending destination. + */ + const targetIndex = direction === 0 || !startedCentered ? nearestColumnIndex(scroller, columns) : resolveSettleTargetIndex(scroller, columns, direction, gestureStartColumnIndex); const targetLeft = scrollLeftToCenterColumn(scroller, columns[targetIndex]); @@ -462,11 +500,15 @@ export function useColumnScrollSnap( lockedDirection = 0; sawHorizontalMovement = false; gestureStartScrollLeft = scroller.scrollLeft; - gestureStartColumnIndex = nearestColumnIndex(scroller, getSnapColumns(scroller)); + const columns = getSnapColumns(scroller); + gestureStartColumnIndex = nearestColumnIndex(scroller, columns); + gestureStartCentered = isColumnCentered(scroller, columns); lastScrollLeft = scroller.scrollLeft; - const clientX = getClientX(event); - gestureStartClientX = clientX; - lastClientX = clientX; + const point = getClientPoint(event); + gestureStartClientX = point?.x ?? null; + lastClientX = point?.x ?? null; + gestureStartClientY = point?.y ?? null; + lastClientY = point?.y ?? null; if (event.type === "wheel") { pointerHeld = false; @@ -491,11 +533,15 @@ export function useColumnScrollSnap( sawHorizontalMovement = false; lockedDirection = 0; gestureStartScrollLeft = scroller.scrollLeft; - gestureStartColumnIndex = nearestColumnIndex(scroller, getSnapColumns(scroller)); + const columns = getSnapColumns(scroller); + gestureStartColumnIndex = nearestColumnIndex(scroller, columns); + gestureStartCentered = isColumnCentered(scroller, columns); lastScrollLeft = scroller.scrollLeft; - const clientX = getClientX(event); - gestureStartClientX = clientX; - lastClientX = clientX; + const point = getClientPoint(event); + gestureStartClientX = point?.x ?? null; + lastClientX = point?.x ?? null; + gestureStartClientY = point?.y ?? null; + lastClientY = point?.y ?? null; if (event.type === "wheel") { pointerHeld = false; @@ -524,13 +570,14 @@ export function useColumnScrollSnap( const handlePointerMove = (event: Event) => { if (!interactionActive || pinnedScrollLeft !== null) return; - const clientX = getClientX(event); - if (clientX === null) return; - lastClientX = clientX; - if ( - gestureStartClientX !== null && - Math.abs(gestureStartClientX - clientX) >= MIN_PAN_CLIENT_PX - ) { + const point = getClientPoint(event); + if (point === null) return; + lastClientX = point.x; + lastClientY = point.y; + // FNXC:BoardNavigation 2026-07-22-21:40: only dominant-horizontal travel is a board pan. + const dx = gestureStartClientX !== null ? Math.abs(gestureStartClientX - point.x) : 0; + const dy = gestureStartClientY !== null ? Math.abs(gestureStartClientY - point.y) : 0; + if (dx >= MIN_PAN_CLIENT_PX && dx > dy) { markMoved(); } };