diff --git a/.changeset/fn-8489-mobile-column-settle.md b/.changeset/fn-8489-mobile-column-settle.md new file mode 100644 index 0000000000..5b92eb5668 --- /dev/null +++ b/.changeset/fn-8489-mobile-column-settle.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Mobile board swipes always settle on a single centered column, never between columns. +category: fix +dev: Hardens useColumnScrollSnap settle to nearest/directional column center; keeps CSS proximity snap (no mandatory). diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 2985c1eb63..d6a093b6ab 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -100,7 +100,7 @@ On mobile board-card detail, **Back to board** also restores the prior board/car ### Mobile Kanban column snapping -On the mobile Kanban board, free-scroll while your finger is down and keep native fling/momentum after lift. Direction is locked at finger-up from the net swipe (not rubber-band ticks). When motion stops, Fusion hard-jumps to the next column **in that scroll direction** (right when scrolling forward, left when scrolling back) and pins until the next touch. CSS proximity stays suspended after a page. During the user pan, the board temporarily suspends native CSS `scroll-snap-type: x proximity`; the JavaScript scroll-end handler is the single magnetism authority and resolves drag-end to exactly one centered column before restoring the proximity baseline. This user-scroll-end behavior does not run for refreshes, resizes, or restored pages, which preserve the column position you chose. It supersedes FN-8235's competing native-drag/JS-drop behavior and intentionally avoids `x mandatory`, because mandatory snapping reintroduced the FN-001 iOS corner-rendering regression during layout changes. +On the mobile Kanban board, free-scroll while your finger is down and keep native fling/momentum after lift. Direction is locked at finger-up from the net swipe (not rubber-band ticks). When motion stops, Fusion hard-jumps to the next column **in that scroll direction** (right when scrolling forward, left when scrolling back) and pins until the next touch. If a user pan ends off-center without a usable direction, it hard-jumps to the nearest column instead, so it never rests between columns. CSS proximity stays suspended after a page. During the user pan, the board temporarily suspends native CSS `scroll-snap-type: x proximity`; the JavaScript scroll-end handler is the single magnetism authority and resolves drag-end to exactly one centered column before restoring the proximity baseline. This user-scroll-end behavior does not run for refreshes, resizes, or restored pages, which preserve the column position you chose. It supersedes FN-8235's competing native-drag/JS-drop behavior and intentionally avoids `x mandatory`, because mandatory snapping reintroduced the FN-001 iOS corner-rendering regression during layout changes. diff --git a/packages/dashboard/app/components/__tests__/board-mobile.test.tsx b/packages/dashboard/app/components/__tests__/board-mobile.test.tsx index 4753067670..201c813431 100644 --- a/packages/dashboard/app/components/__tests__/board-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/board-mobile.test.tsx @@ -212,11 +212,11 @@ describe("Board and Column mobile CSS", () => { expectRuleToContain(mobileSection, ".board", "scroll-snap-type: x proximity;"); }); - it("contains .board scroll-behavior: smooth in the mobile media block", () => { + it("keeps .board scroll-behavior auto for the JavaScript hard settle", () => { const css = loadAllAppCss(); const mobileSection = getMainMobileSection(css); - expectRuleToContain(mobileSection, ".board", "scroll-behavior: smooth;"); + expectRuleToContain(mobileSection, ".board", "scroll-behavior: auto;"); }); it("contains .board > .column width: 300px in the mobile media block", () => { diff --git a/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts b/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts index 106b546d1d..d7ea559b73 100644 --- a/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts @@ -1,6 +1,7 @@ import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + isColumnCentered, resolvePanDirection, resolveTargetIndexInScrollDirection, useColumnScrollSnap, @@ -111,6 +112,17 @@ describe("resolvePanDirection", () => { }); }); +describe("isColumnCentered", () => { + it("recognizes only an integer column-centering target", () => { + const scroller = createScroller(3, COLUMN_WIDTH); + const columns = [...scroller.children] as HTMLElement[]; + + expect(isColumnCentered(scroller, columns)).toBe(true); + scroller.scrollLeft = 40; + expect(isColumnCentered(scroller, columns)).toBe(false); + }); +}); + describe("resolveTargetIndexInScrollDirection", () => { it("forward from column 0 always goes right to column 1", () => { const scroller = createScroller(3, 0); @@ -169,6 +181,26 @@ describe("useColumnScrollSnap", () => { expect(scroller.scrollLeft).toBe(COLUMN_WIDTH); }); + it("hard-settles a zero-direction pan at the nearest column center", () => { + const scroller = createScroller(3, 40); + renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => { + dispatchPointerEvent(scroller, "pointerdown", 200); + // A weak/reversed gesture can have a real pan but zero net direction at lift. + scroller.scrollLeft = 60; + scroller.dispatchEvent(new Event("scroll")); + scroller.scrollLeft = 40; + scroller.dispatchEvent(new Event("scroll")); + dispatchPointerEvent(scroller, "pointerup", 200); + }); + settleAfterMomentum(); + + // Regression: proximity alone previously left this invalid mid-column rest at 40. + expect(scroller.scrollLeft).toBe(0); + expect(isColumnCentered(scroller, [...scroller.children] as HTMLElement[])).toBe(true); + }); + it("does not reverse direction when post-lift scroll rubber-bands", () => { 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 22ddce5f54..8d7cdd1580 100644 --- a/packages/dashboard/app/hooks/useColumnScrollSnap.ts +++ b/packages/dashboard/app/hooks/useColumnScrollSnap.ts @@ -84,6 +84,17 @@ function scrollLeftToCenterColumn(scroller: HTMLElement, column: HTMLElement): n return Math.round(scroller.scrollLeft + columnRect.left + columnRect.width / 2 - viewportCenter); } +/** Whether the viewport is already centered on one of its eligible snap columns. */ +export function isColumnCentered( + scroller: HTMLElement, + columns: HTMLElement[], + tolerance = CENTER_TOLERANCE_PX, +): boolean { + if (columns.length === 0) return false; + const nearest = nearestColumnIndex(scroller, columns); + return Math.abs(scroller.scrollLeft - scrollLeftToCenterColumn(scroller, columns[nearest])) <= tolerance; +} + /** * Resolve pan direction from the full gesture (net deltas only). * Do NOT pass last micro-tick direction for settle — rubber-band flips it. @@ -307,7 +318,7 @@ export function useColumnScrollSnap( gestureStartClientX = null; lastClientX = null; - if (!hadPanIntent || direction === 0) { + if (!hadPanIntent) { restoreNativeSnap(); return; } @@ -324,7 +335,21 @@ export function useColumnScrollSnap( return; } - const targetIndex = resolveTargetIndexInScrollDirection(scroller, columns, direction); + /* + FNXC:BoardNavigation 2026-07-22-18:30: + A user-driven mobile settle must rest at the integer center of exactly one `.column`, + never between columns. Keep CSS proximity (not prohibited mandatory snap) and free + scrolling while held: a locked direction pages in that direction, while an off-center + zero-direction settle hard-jumps to its nearest center and pins until the next touch. + */ + if (direction === 0 && isColumnCentered(scroller, columns)) { + restoreNativeSnap(); + return; + } + + const targetIndex = direction === 0 + ? nearestColumnIndex(scroller, columns) + : resolveTargetIndexInScrollDirection(scroller, columns, direction); const targetLeft = scrollLeftToCenterColumn(scroller, columns[targetIndex]); applySnapTo(targetLeft); };