diff --git a/.changeset/fair-boards-page-one-column.md b/.changeset/fair-boards-page-one-column.md new file mode 100644 index 0000000000..d6434d638d --- /dev/null +++ b/.changeset/fair-boards-page-one-column.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Mobile board swipes from the first or last column now advance one column instead of two. +category: fix +dev: `scrollLeftToCenterColumn` clamps to the scroller's reachable range so edge columns (narrower than the phone viewport) count as centered; `commitDirectionalPage` also clamps its mid-transit origin against the gesture-start column so drag travel can never be counted twice. diff --git a/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts b/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts index 00a9f21829..90310326f5 100644 --- a/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts @@ -291,6 +291,139 @@ describe("resolveSettleTargetIndex", () => { }); }); +/* +FNXC:BoardNavigation 2026-07-26-09:15: +Phone geometry: board columns (min-width 300px) are NARROWER than the phone viewport, so the first +and last columns can never reach their ideal centered scrollLeft (it is negative / past max). This +is the geometry the two-column edge jump only reproduces under — the default `createScroller` makes +columns exactly viewport-wide, where every column is perfectly centerable. +*/ +const NARROW_VIEWPORT_WIDTH = 390; +const NARROW_COLUMN_WIDTH = 300; + +function createNarrowColumnScroller(columnCount: number, initialScrollLeft: number): HTMLElement { + const scroller = document.createElement("main"); + const contentWidth = columnCount * NARROW_COLUMN_WIDTH; + Object.defineProperty(scroller, "clientWidth", { configurable: true, value: NARROW_VIEWPORT_WIDTH }); + Object.defineProperty(scroller, "scrollWidth", { configurable: true, value: contentWidth }); + scroller.getBoundingClientRect = () => new DOMRect(0, 0, NARROW_VIEWPORT_WIDTH, 200); + const maxScrollLeft = Math.max(0, contentWidth - NARROW_VIEWPORT_WIDTH); + let scrollLeft = initialScrollLeft; + Object.defineProperty(scroller, "scrollLeft", { + configurable: true, + get: () => scrollLeft, + // Mirror the browser: positions outside the scrollable range are clamped, never stored. + set: (value: number) => { + scrollLeft = Math.min(Math.max(value, 0), maxScrollLeft); + }, + }); + scroller.setPointerCapture = vi.fn(); + scroller.releasePointerCapture = vi.fn(); + scroller.hasPointerCapture = vi.fn(() => false); + for (let index = 0; index < columnCount; index++) { + const column = document.createElement("section"); + column.className = "column"; + column.getBoundingClientRect = () => + new DOMRect(index * NARROW_COLUMN_WIDTH - scrollLeft, 0, NARROW_COLUMN_WIDTH, 200); + scroller.append(column); + } + document.body.append(scroller); + return scroller; +} + +/** scrollLeft that rests column `index` at its (range-clamped) center. */ +function narrowColumnRest(index: number, columnCount: number): number { + const ideal = + index * NARROW_COLUMN_WIDTH + NARROW_COLUMN_WIDTH / 2 - NARROW_VIEWPORT_WIDTH / 2; + const maxScrollLeft = Math.max(0, columnCount * NARROW_COLUMN_WIDTH - NARROW_VIEWPORT_WIDTH); + return Math.min(Math.max(Math.round(ideal), 0), maxScrollLeft); +} + +/** One deliberate drag: finger travel plus the board scroll it produced, then lift. */ +function dispatchDrag( + scroller: HTMLElement, + options: { scrollDelta: number; clientDelta: number }, +): void { + const { scrollDelta, clientDelta } = options; + scroller.dispatchEvent(new Event("touchstart")); + dispatchPointerEvent(scroller, "pointerdown", 200); + dispatchPointerEvent(scroller, "pointermove", 200 - clientDelta); + scroller.scrollLeft = scroller.scrollLeft + scrollDelta; + scroller.dispatchEvent(new Event("scroll")); + dispatchPointerEvent(scroller, "pointerup", 200 - clientDelta); +} + +describe("edge columns narrower than the viewport", () => { + beforeEach(() => { + vi.useFakeTimers(); + stubViewport("mobile"); + }); + + afterEach(() => { + document.body.replaceChildren(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("counts the far-left rest position as centered on the first column", () => { + const scroller = createNarrowColumnScroller(4, 0); + expect(isColumnCentered(scroller, [...scroller.children] as HTMLElement[])).toBe(true); + }); + + it("counts the far-right rest position as centered on the last column", () => { + const columnCount = 4; + const scroller = createNarrowColumnScroller(columnCount, narrowColumnRest(columnCount - 1, columnCount)); + expect(isColumnCentered(scroller, [...scroller.children] as HTMLElement[])).toBe(true); + }); + + /* + FNXC:BoardNavigation 2026-07-26-09:15: + Original symptom: one swipe starting on the far-left column advanced TWO columns, while the same + swipe from a scrolled-over position advanced one. Assert one column of travel from every resting + position — both clamped edges and the interior. + */ + it.each([ + { label: "far-left edge", from: 0, expected: 1 }, + { label: "interior column", from: 1, expected: 2 }, + ])("advances exactly one column per forward swipe from the $label", ({ from, expected }) => { + const columnCount = 4; + const scroller = createNarrowColumnScroller(columnCount, narrowColumnRest(from, columnCount)); + renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + + // Drag far enough that the NEAREST column has already flipped to the next one at lift. + act(() => dispatchDrag(scroller, { scrollDelta: 200, clientDelta: 200 })); + settleAfterMomentum(); + + expect(scroller.scrollLeft).toBe(narrowColumnRest(expected, columnCount)); + }); + + it("advances exactly one column per backward swipe from the far-right edge", () => { + const columnCount = 4; + const scroller = createNarrowColumnScroller(columnCount, narrowColumnRest(columnCount - 1, columnCount)); + renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => dispatchDrag(scroller, { scrollDelta: -200, clientDelta: -200 })); + settleAfterMomentum(); + + expect(scroller.scrollLeft).toBe(narrowColumnRest(columnCount - 2, columnCount)); + }); + + it("never pins an unreachable scroll position at an edge", () => { + const scroller = createNarrowColumnScroller(4, narrowColumnRest(1, 4)); + renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => dispatchDrag(scroller, { scrollDelta: -400, clientDelta: -400 })); + settleAfterMomentum(); + const settled = scroller.scrollLeft; + + // The pin watchdog must agree with the clamped position instead of fighting it forever. + act(() => vi.advanceTimersByTime(200)); + expect(scroller.scrollLeft).toBe(settled); + expect(settled).toBe(0); + }); +}); + describe("useColumnScrollSnap", () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/packages/dashboard/app/hooks/useColumnScrollSnap.ts b/packages/dashboard/app/hooks/useColumnScrollSnap.ts index 6467c5cb93..f746b9d94b 100644 --- a/packages/dashboard/app/hooks/useColumnScrollSnap.ts +++ b/packages/dashboard/app/hooks/useColumnScrollSnap.ts @@ -222,13 +222,37 @@ export function nearestColumnIndex(scroller: HTMLElement, columns: HTMLElement[] return nearestIndex; } -/** scrollLeft that centers `column` in the scroller viewport (integer pixels). */ +/* +FNXC:BoardNavigation 2026-07-26-09:15: +A swipe starting on the FAR-LEFT column jumped two columns while the same swipe mid-board moved one. +Cause: columns are narrower than the phone viewport, so the edge columns' ideal centered scrollLeft +falls OUTSIDE the reachable range (negative at the left edge, past max at the right edge). The board +therefore never read as "centered" while resting at an edge, `gestureStartCentered` was false, and +`commitDirectionalPage` fell back to taking its origin at RELEASE — which had already advanced onto +the next column — so the +1 page landed two columns over. +Clamping the centering target to the reachable scroll range makes an edge rest count as centered +(rest position IS the column's reachable center), so edge swipes page exactly one column like every +other position. It also stops `applySnapTo` from pinning an unreachable value, which left the pin +watchdog hard-jumping to a scrollLeft the browser keeps clamping away. +*/ +/** + * scrollLeft that centers `column` in the scroller viewport (integer pixels), clamped to the + * scroller's reachable range so edge columns resolve to the position they actually rest at. + * + * The upper clamp is skipped when `scrollWidth` is unusable (jsdom reports 0); the lower clamp at 0 + * is always valid. + */ function scrollLeftToCenterColumn(scroller: HTMLElement, column: HTMLElement): number { const scrollerRect = scroller.getBoundingClientRect(); const viewportWidth = scroller.clientWidth || scrollerRect.width; const viewportCenter = scrollerRect.left + viewportWidth / 2; const columnRect = column.getBoundingClientRect(); - return Math.round(scroller.scrollLeft + columnRect.left + columnRect.width / 2 - viewportCenter); + const ideal = Math.round( + scroller.scrollLeft + columnRect.left + columnRect.width / 2 - viewportCenter, + ); + const maxScrollLeft = scroller.scrollWidth - viewportWidth; + const upperBound = maxScrollLeft > 0 ? maxScrollLeft : Number.POSITIVE_INFINITY; + return Math.min(Math.max(ideal, 0), upperBound); } /** Whether the viewport is already centered on one of its eligible snap columns. */ @@ -740,8 +764,20 @@ 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; + /* + FNXC:BoardNavigation 2026-07-26-09:15: + A gesture begun mid-transit has no trustworthy rest origin, so it pages from where it actually + is — but that origin must never sit FURTHER ALONG the travel direction than the column the + gesture started on, or the drag gets counted twice (once as travel, once as a bumped origin) + and the board advances two columns for a one-column swipe. `resolveFlingTargetIndex` still + floors the result at `nearestIndex`, so a long drag keeps its own landing point. + */ + const startIndex = Math.min(Math.max(gestureStartColumnIndex, 0), columns.length - 1); + const originIndex = gestureStartCentered + ? gestureStartColumnIndex + : direction > 0 + ? Math.min(nearestIndex, startIndex) + : Math.max(nearestIndex, startIndex); /* FNXC:BoardNavigation 2026-07-25-09:40: Net gesture travel gates multi-column reach. Take the larger of the board's own scroll delta