diff --git a/.changeset/fn-010-smooth-mobile-board-release.md b/.changeset/fn-010-smooth-mobile-board-release.md new file mode 100644 index 0000000000..6be8106368 --- /dev/null +++ b/.changeset/fn-010-smooth-mobile-board-release.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Make mobile Board column releases settle smoothly into the valid column. +category: fix +dev: Keeps the existing target, edge-clamping, reduced-motion, and compositor-fencing behavior. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index bbe856e063..1f3b7b8914 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -155,7 +155,10 @@ 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. 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. + +On the mobile Kanban board, free-scroll while your finger is down. At release, Fusion resolves the existing directional or nearest-column target and moves continuously to that exact reachable column, so the board feels fluid instead of abruptly locking or visibly hard-jumping. Direction is locked at finger-up from the net swipe (not rubber-band ticks), and the result pins until the next touch. A direction-zero or off-center release uses the same controlled nearest-column settle, so the board never rests between columns. Reduced-motion preferences, negligible-distance corrections, or unavailable animation capability use an immediate safe landing instead. + +During a user pan, the board temporarily suspends native CSS `scroll-snap-type: x proximity`; the JavaScript release handler is the single magnetism authority and restores the proximity baseline after settling. This user-scroll behavior does not run for refreshes, resizes, or restored pages, which preserve the column position you chose. The mobile-only contract 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 abba8ce35c..2c6d33c440 100644 --- a/packages/dashboard/app/components/__tests__/board-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/board-mobile.test.tsx @@ -103,8 +103,9 @@ describe("mobile board magnetic column snap wiring (FN-8235)", () => { expect(boardSource).toContain('import { useColumnScrollSnap } from "../hooks/useColumnScrollSnap";'); expect(boardSource).toContain("useColumnScrollSnap(boardElement, { mobileOnly: true });"); + expect(boardSource).toContain('const boardMousePanClassName = `board board-workflow-columns'); expect(boardSource.match(/ref=\{setBoardRef\}/g)).toHaveLength(2); - expect(boardSource.match(/className="board board-workflow-columns"/g)).toHaveLength(2); + expect(boardSource.match(/className=\{boardMousePanClassName\}/g)).toHaveLength(2); // The legacy `
` render is gone; assert it stays gone. expect(boardSource).not.toContain('
'); }); diff --git a/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts b/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts index 90310326f5..1ef54f66bc 100644 --- a/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts @@ -447,7 +447,13 @@ describe("useColumnScrollSnap", () => { expect(scroller.scrollLeft).toBe(COLUMN_WIDTH); }); - it("hard-settles a zero-direction pan at the nearest column center", () => { + /* + FNXC:BoardNavigation 2026-08-18-19:10: + A direction-zero release still has a meaningful correction when the finger leaves the viewport + between columns. Keep that correction on the normal-motion path so proximity does not create an + abrupt lock, while the nearest reachable column remains the same target. + */ + it("smoothly settles a zero-direction pan at the nearest column center", () => { const scroller = createScroller(3, 40); renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); @@ -460,6 +466,22 @@ describe("useColumnScrollSnap", () => { scroller.dispatchEvent(new Event("scroll")); dispatchPointerEvent(scroller, "pointerup", 200); }); + expect(scroller.scrollLeft).toBe(40); + + const samples: number[] = []; + for (let index = 0; index < 4; index++) { + act(() => { + vi.advanceTimersByTime(32); + }); + samples.push(scroller.scrollLeft); + } + + expect(samples.some((value) => value > 0 && value < 40)).toBe(true); + expect(samples.every((value) => value >= 0 && value <= 40)).toBe(true); + for (let index = 1; index < samples.length; index++) { + expect(samples[index]).toBeLessThanOrEqual(samples[index - 1]); + } + settleAfterMomentum(); // Regression: proximity alone previously left this invalid mid-column rest at 40. @@ -505,6 +527,40 @@ describe("useColumnScrollSnap", () => { expect(scroller.scrollLeft).toBe(0); }); + /* + FNXC:BoardNavigation 2026-08-18-19:10: + Backward phone releases must use the same continuous normal-motion path as forward releases; + direction changes the existing target only, not whether the board visibly jumps to it. + */ + it("smoothly settles a backward release without overshooting the target", () => { + const scroller = createScroller(3, COLUMN_WIDTH); + renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => { + dispatchPointerEvent(scroller, "pointerdown", 100); + dispatchPointerEvent(scroller, "pointermove", 140); + scroller.scrollLeft = COLUMN_WIDTH - 8; + scroller.dispatchEvent(new Event("scroll")); + dispatchPointerEvent(scroller, "pointerup", 140); + }); + + const samples: number[] = []; + for (let index = 0; index < 4; index++) { + act(() => { + vi.advanceTimersByTime(32); + }); + samples.push(scroller.scrollLeft); + } + expect(samples.some((value) => value > 0 && value < COLUMN_WIDTH - 8)).toBe(true); + expect(samples.every((value) => value >= 0 && value <= COLUMN_WIDTH - 8)).toBe(true); + for (let index = 1; index < samples.length; index++) { + expect(samples[index]).toBeLessThanOrEqual(samples[index - 1]); + } + + settleAfterMomentum(); + expect(scroller.scrollLeft).toBe(0); + }); + /* FNXC:BoardNavigation 2026-07-24-11:20: Free-scroll while the finger is DOWN is still untouched. What changed is after lift: the hook @@ -547,12 +603,18 @@ describe("useColumnScrollSnap", () => { act(() => dispatchShortSwipe(scroller, { scrollDelta: 8, clientDelta: 20 })); - act(() => { - vi.advanceTimersByTime(64); - }); - const midFlight = scroller.scrollLeft; - expect(midFlight).toBeGreaterThan(8); - expect(midFlight).toBeLessThan(COLUMN_WIDTH); + const samples: number[] = []; + for (let index = 0; index < 4; index++) { + act(() => { + vi.advanceTimersByTime(32); + }); + samples.push(scroller.scrollLeft); + } + expect(samples.some((value) => value > 8 && value < COLUMN_WIDTH)).toBe(true); + expect(samples.every((value) => value >= 8 && value <= COLUMN_WIDTH)).toBe(true); + for (let index = 1; index < samples.length; index++) { + expect(samples[index]).toBeGreaterThanOrEqual(samples[index - 1]); + } settleAfterMomentum(); expect(scroller.scrollLeft).toBe(COLUMN_WIDTH); @@ -809,7 +871,7 @@ describe("useColumnScrollSnap", () => { /* 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. + must smoothly settle to the nearest column center so the board never rests between columns. */ it("tap during momentum settles to nearest column, not the cancelled swipe direction", () => { const scroller = createScroller(3, 0); @@ -884,6 +946,37 @@ describe("useColumnScrollSnap", () => { expect([0, COLUMN_WIDTH, COLUMN_WIDTH * 2]).toContain(scroller.scrollLeft); }); + /* + FNXC:BoardNavigation 2026-08-18-19:10: + A nearest-column animation owns inline overflow only while it runs. Unmounting the Board must + restore that style and cancel future frame writes rather than leaving a frozen or stale scroller. + */ + it("restores styles and cancels a nearest-column animation on unmount", () => { + const scroller = createScroller(3, 40); + scroller.style.overflowX = "auto"; + const { unmount } = renderHook(() => + useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }), + ); + + act(() => { + dispatchPointerEvent(scroller, "pointerdown", 200); + scroller.dispatchEvent(new Event("scroll")); + dispatchPointerEvent(scroller, "pointerup", 200); + vi.advanceTimersByTime(64); + }); + const inFlightPosition = scroller.scrollLeft; + expect(inFlightPosition).toBeGreaterThan(0); + expect(inFlightPosition).toBeLessThan(40); + expect(scroller.style.overflowX).toBe("hidden"); + + unmount(); + expect(scroller.style.overflowX).toBe("auto"); + act(() => { + vi.advanceTimersByTime(400); + }); + expect(scroller.scrollLeft).toBe(inFlightPosition); + }); + it("starts a new directional settle after a pan that continues from a mid-momentum re-touch", () => { 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 f746b9d94b..45dd113ec7 100644 --- a/packages/dashboard/app/hooks/useColumnScrollSnap.ts +++ b/packages/dashboard/app/hooks/useColumnScrollSnap.ts @@ -7,17 +7,18 @@ Wrong-way snaps came from (1) settle direction using the last micro scroll tick rubber-band/fling end often reverses for a frame — and (2) origin±nearest hybrid targets. Direction is locked at finger-up from net gesture delta only (never post-lift ticks). Target is always the next column in that scroll direction from the current viewport (classic -directional page snap). Pin until next touch; hard-jump kills residual fling. +directional page snap). Pin until next touch; the controlled animation kills residual fling. 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. +idle timer would settle 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 +between columns. Tap-to-stop and zero-pan lifts animate to the nearest center (not the cancelled +swipe's directional page), with hard writes reserved for reduced motion, unavailable animation, +negligible distance, and compositor fencing. Directional paging still applies only when the settle gesture itself had pan intent. */ /* @@ -355,7 +356,8 @@ function hardJumpScrollLeft(scroller: HTMLElement, targetLeft: number): void { } /** - * Mobile board: free-scroll + momentum, then hard-page only in the scroll direction. + * Mobile board: free-scroll while held, then settle to one reachable column in the locked + * direction. * * FNXC:BoardNavigation 2026-07-22-18:00: * Lock settle direction at finger-up from net gesture deltas. Pin until next touch. @@ -364,6 +366,11 @@ function hardJumpScrollLeft(scroller: HTMLElement, targetLeft: number): void { * Target via resolveSettleTargetIndex: nearest (mostly-on-screen) column, clamped to at least * one column of progress from the gesture's origin column — commits short swipes without * overshooting a fling that already decelerated onto a column. + * + * FNXC:BoardNavigation 2026-08-18-19:10: + * Phone releases use one controlled normal-motion settle for both directional and nearest-column + * corrections. Exact reachable landing, reduced-motion immediacy, and compositor pin fencing stay + * unchanged while the release no longer visibly hard-jumps. */ export function useColumnScrollSnap( scroller: HTMLElement | null, @@ -588,7 +595,7 @@ export function useColumnScrollSnap( /** * Animate to a column center over `durationMs`, then pin as a normal settle. * - * Falls back to the instant hard jump when motion is reduced, `requestAnimationFrame` is + * Falls back to an instant hard write when motion is reduced, `requestAnimationFrame` is * unavailable, or the distance is not worth animating. */ const animateSnapTo = (targetLeft: number, durationMs: number) => { @@ -618,6 +625,10 @@ export function useColumnScrollSnap( const elapsed = now() - startedAt; const progress = elapsed / durationMs; if (progress >= 1) { + // FNXC:BoardNavigation 2026-08-18-19:26: Complete the final normal-motion frame before + // compositor fencing so a late animation frame cannot turn the remaining distance into + // the abrupt hard release jump this phone interaction forbids. + scroller.scrollLeft = target; cancelPageAnimation(); applySnapTo(target); return; @@ -630,9 +641,12 @@ export function useColumnScrollSnap( }; /** - * 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. + * FNXC:BoardNavigation 2026-08-18-19:10: + * Smoothly settle to the nearest reachable center when normal motion is meaningful. The + * animation helper still chooses an immediate hard write for reduced motion, unavailable rAF, + * or negligible distance, and keeps the final compositor pin as the single authority. + * 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); @@ -650,7 +664,10 @@ export function useColumnScrollSnap( return true; } const targetIndex = nearestColumnIndex(scroller, columns); - applySnapTo(scrollLeftToCenterColumn(scroller, columns[targetIndex])); + animateSnapTo( + scrollLeftToCenterColumn(scroller, columns[targetIndex]), + resolvePageAnimationMs(1), + ); return true; }; @@ -693,7 +710,8 @@ export function useColumnScrollSnap( /* 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. + rest between columns — nearest-center only. Use the same controlled animation as a + direction-zero pan, without reusing a cancelled swipe's direction. */ if (!hadPanIntent) { snapToNearestColumnIfNeeded(); @@ -716,8 +734,8 @@ export function useColumnScrollSnap( 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. + scrolling while held: a locked direction and an off-center zero-direction settle both use + the controlled animation, then pin until the next touch. */ if (direction === 0 && isColumnCentered(scroller, columns)) { restoreNativeSnap(); @@ -735,7 +753,7 @@ export function useColumnScrollSnap( ? nearestColumnIndex(scroller, columns) : resolveSettleTargetIndex(scroller, columns, direction, gestureStartColumnIndex); const targetLeft = scrollLeftToCenterColumn(scroller, columns[targetIndex]); - applySnapTo(targetLeft); + animateSnapTo(targetLeft, resolvePageAnimationMs(1)); }; const armIdleSettle = () => {