From 55c0b7532c32680d19bd90c9b9d4df4e36cbea65 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 22 Jul 2026 15:38:42 -0700 Subject: [PATCH] fix(dashboard): always settle mobile board on a column center After tap-to-stop or zero-pan lifts, hard-jump to the nearest column so the board never rests between lanes, while directional paging still applies only when the settle gesture itself panned. --- .../__tests__/useColumnScrollSnap.test.ts | 61 ++++++++++++++++--- .../app/hooks/useColumnScrollSnap.ts | 41 ++++++++++++- 2 files changed, 93 insertions(+), 9 deletions(-) diff --git a/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts b/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts index f05a088bde..019f69c771 100644 --- a/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts @@ -344,35 +344,81 @@ describe("useColumnScrollSnap", () => { }); /* - FNXC:BoardNavigation 2026-07-22-15:10: - Tap-to-stop during post-lift momentum must not hard-jump using the original swipe direction. + FNXC:BoardNavigation 2026-07-22-15:10 / 2026-07-22-15:26: + Tap-to-stop during post-lift momentum must not page with the original swipe direction, and + must hard-jump to the nearest column center so the board never rests between columns. */ - it("does not jump when the user taps during momentum after a swipe", () => { + it("tap during momentum settles to nearest column, not the cancelled swipe direction", () => { const scroller = createScroller(3, 0); renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); act(() => { - // Start a forward swipe and lift so the idle settle is armed. + // Forward swipe arms a rightward directional settle (would page to column 1). dispatchPointerEvent(scroller, "pointerdown", 200); dispatchPointerEvent(scroller, "pointermove", 160); scroller.scrollLeft = 30; scroller.dispatchEvent(new Event("scroll")); dispatchPointerEvent(scroller, "pointerup", 160); - // Coast a bit more (native fling), then the user taps to stop mid-travel. + // Coast only slightly — still nearest to column 0 — then tap to stop. + scroller.scrollLeft = 40; + scroller.dispatchEvent(new Event("scroll")); + dispatchPointerEvent(scroller, "pointerdown", 100); + dispatchPointerEvent(scroller, "pointerup", 100); + }); + + settleAfterMomentum(); + act(() => { + vi.advanceTimersByTime(500); + }); + + // Nearest is column 0; original rightward settle would have jumped to COLUMN_WIDTH. + expect(scroller.scrollLeft).toBe(0); + expect(isColumnCentered(scroller, [...scroller.children] as HTMLElement[])).toBe(true); + }); + + it("tap during momentum past the midpoint snaps to the nearer column center", () => { + const scroller = createScroller(3, 0); + renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => { + dispatchPointerEvent(scroller, "pointerdown", 200); + dispatchPointerEvent(scroller, "pointermove", 160); + scroller.scrollLeft = 30; + scroller.dispatchEvent(new Event("scroll")); + dispatchPointerEvent(scroller, "pointerup", 160); + + // Past the midpoint toward column 1 — nearest is column 1. scroller.scrollLeft = 55; scroller.dispatchEvent(new Event("scroll")); dispatchPointerEvent(scroller, "pointerdown", 100); dispatchPointerEvent(scroller, "pointerup", 100); }); - // Without re-baselining the second touch, the original rightward settle would jump to COLUMN_WIDTH. settleAfterMomentum(); act(() => { vi.advanceTimersByTime(500); }); - expect(scroller.scrollLeft).toBe(55); + expect(scroller.scrollLeft).toBe(COLUMN_WIDTH); + expect(isColumnCentered(scroller, [...scroller.children] as HTMLElement[])).toBe(true); + }); + + it("never rests between columns after a zero-direction settle", () => { + const scroller = createScroller(3, 40); + renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => { + dispatchPointerEvent(scroller, "pointerdown", 200); + scroller.scrollLeft = 40; + scroller.dispatchEvent(new Event("scroll")); + dispatchPointerEvent(scroller, "pointerup", 200); + }); + settleAfterMomentum(); + + const columns = [...scroller.children] as HTMLElement[]; + expect(isColumnCentered(scroller, columns)).toBe(true); + expect([0, COLUMN_WIDTH, COLUMN_WIDTH * 2]).toContain(scroller.scrollLeft); }); it("starts a new directional settle after a pan that continues from a mid-momentum re-touch", () => { @@ -398,6 +444,7 @@ describe("useColumnScrollSnap", () => { settleAfterMomentum(); expect(scroller.scrollLeft).toBe(0); + expect(isColumnCentered(scroller, [...scroller.children] as HTMLElement[])).toBe(true); }); it("does not attach on non-phone desktop", () => { diff --git a/packages/dashboard/app/hooks/useColumnScrollSnap.ts b/packages/dashboard/app/hooks/useColumnScrollSnap.ts index ae6f2d4b5f..299746401d 100644 --- a/packages/dashboard/app/hooks/useColumnScrollSnap.ts +++ b/packages/dashboard/app/hooks/useColumnScrollSnap.ts @@ -13,6 +13,12 @@ FNXC:BoardNavigation 2026-07-22-15:10: A tap during post-lift momentum must cancel the pending directional settle and re-baseline the gesture at the current scrollLeft (pointerHeld true). Otherwise the original swipe's idle timer still hard-jumps the board away from where the user stopped. + +FNXC:BoardNavigation 2026-07-22-15:26: +After any user touch sequence ends, the board must rest on exactly one column center — never +between columns. Tap-to-stop and zero-pan lifts hard-jump to the nearest center (not the +cancelled swipe's directional page). Directional paging still applies only when the settle +gesture itself had pan intent. */ /** After lift/cancel/wheel: wait for scroll idle (momentum finished) before paging. */ const SCROLL_IDLE_SETTLE_MS = 48; @@ -325,6 +331,31 @@ export function useColumnScrollSnap( reassertPinnedScrollLeft(); }; + /** + * FNXC:BoardNavigation 2026-07-22-15:26: + * Hard-jump to the nearest column center when off-center. Returns true when a snap + * applied (or already centered); false only when there are no usable snap columns. + */ + const snapToNearestColumnIfNeeded = (): boolean => { + const columns = getSnapColumns(scroller); + if (columns.length < 2) { + restoreNativeSnap(); + return false; + } + const viewportWidth = scroller.clientWidth || scroller.getBoundingClientRect().width; + if (viewportWidth <= 0) { + restoreNativeSnap(); + return false; + } + if (isColumnCentered(scroller, columns)) { + restoreNativeSnap(); + return true; + } + const targetIndex = nearestColumnIndex(scroller, columns); + applySnapTo(scrollLeftToCenterColumn(scroller, columns[targetIndex])); + return true; + }; + const snapInScrollDirection = () => { clearIdleTimer(); if (!interactionActive) return; @@ -353,8 +384,13 @@ export function useColumnScrollSnap( gestureStartClientX = null; lastClientX = null; + /* + FNXC:BoardNavigation 2026-07-22-15:26: + No pan on this settle gesture (tap-to-stop after re-baseline, pure tap): still never + rest between columns — nearest-center only. Do not reuse a cancelled swipe's direction. + */ if (!hadPanIntent) { - restoreNativeSnap(); + snapToNearestColumnIfNeeded(); return; } @@ -522,8 +558,9 @@ export function useColumnScrollSnap( if (sawHorizontalMovement || lockedDirection !== 0) { armIdleSettle(); } else { + // FNXC:BoardNavigation 2026-07-22-15:26: Cancelled zero-pan touch must not leave mid-column. interactionActive = false; - restoreNativeSnap(); + snapToNearestColumnIfNeeded(); } };