From 119fcedcfee67efa076ec0dad7009aa26992b7cc Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 18 Jul 2026 09:05:51 -0700 Subject: [PATCH] FN-8276: unify mobile board snap behavior Make JavaScript the sole authority for mobile Kanban pan settlement. - Suspend native proximity snapping while a user pan settles, then restore its inline baseline. - Restore snapping after wheels with no horizontal scroll as well as pointer and touch completion. - Document the unified magnetism behavior and cover restoration paths with hook tests. Files changed: .changeset/fn-8276-unify-board-magnetism.md | 7 +++ docs/dashboard-guide.md | 3 +- .../hooks/__tests__/useColumnScrollSnap.test.ts | 53 +++++++++++++++-- .../dashboard/app/hooks/useColumnScrollSnap.ts | 66 ++++++++++++++++++++-- 4 files changed, 119 insertions(+), 10 deletions(-) Fusion-Task-Id: FN-8276 Fusion-Task-Lineage: 1ff3ae24-51e8-4c4f-aa5a-7a7b7c84d37c Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-8276-unify-board-magnetism.md | 7 ++ docs/dashboard-guide.md | 3 +- .../__tests__/useColumnScrollSnap.test.ts | 53 +++++++++++++-- .../app/hooks/useColumnScrollSnap.ts | 66 +++++++++++++++++-- 4 files changed, 119 insertions(+), 10 deletions(-) create mode 100644 .changeset/fn-8276-unify-board-magnetism.md diff --git a/.changeset/fn-8276-unify-board-magnetism.md b/.changeset/fn-8276-unify-board-magnetism.md new file mode 100644 index 0000000000..fb3fcd8eb9 --- /dev/null +++ b/.changeset/fn-8276-unify-board-magnetism.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Mobile Kanban swipes now settle on exactly one column with no stuck-between-columns state. +category: fix +dev: useColumnScrollSnap suspends native scroll-snap (inline scroll-snap-type:none) during a user pan and restores the x proximity baseline after its JS scroll-end snap, unifying the two magnetism systems from FN-8235; never uses x mandatory (FN-001). diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 801494ceb2..27e5fbfc8c 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -84,8 +84,9 @@ On mobile board-card detail, **Back to board** also restores the prior board/car ### Mobile Kanban column snapping -After a horizontal swipe on the mobile Kanban board, Fusion smoothly settles the viewport on the nearest column so it does not rest between two columns. This is a user-scroll-end behavior only; refreshes, resizes, and restored pages preserve the column position you chose. The board intentionally keeps CSS `scroll-snap-type: x proximity` rather than using `x mandatory`, because mandatory snapping reintroduced the FN-001 iOS corner-rendering regression during layout changes. +After a horizontal swipe on the mobile Kanban board, Fusion smoothly settles the viewport on the nearest column so it does not rest between two columns. 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. + This behavior used to be mobile-only, and now applies across all viewports. diff --git a/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts b/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts index fa36f8d541..3a9456ad22 100644 --- a/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts @@ -65,13 +65,42 @@ describe("useColumnScrollSnap", () => { vi.restoreAllMocks(); }); - it("centers the nearest column after verified user horizontal movement ends", () => { + it("unifies a user pan into one JS snap and restores the CSS proximity baseline", () => { const scroller = createScroller(); renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); - act(() => dispatchUserPan(scroller)); + act(() => scroller.dispatchEvent(new Event("pointerdown"))); + expect(scroller.style.scrollSnapType).toBe("none"); + act(() => { + scroller.scrollLeft = 10; + scroller.dispatchEvent(new Event("scroll")); + scroller.dispatchEvent(new Event("scrollend")); + }); + + expect(scroller.scrollTo).toHaveBeenCalledTimes(1); expect(scroller.scrollTo).toHaveBeenCalledWith({ left: 40, behavior: "smooth" }); + expect(scroller.style.scrollSnapType).toBe("none"); + + act(() => scroller.dispatchEvent(new Event("scrollend"))); + expect(scroller.style.scrollSnapType).toBe(""); + expect(scroller.scrollTo).toHaveBeenCalledTimes(1); + }); + + it("restores a pre-existing inline snap value after completion and cleanup", () => { + const scroller = createScroller(); + scroller.style.scrollSnapType = "x proximity"; + const { unmount } = renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => dispatchUserPan(scroller)); + expect(scroller.style.scrollSnapType).toBe("none"); + act(() => scroller.dispatchEvent(new Event("scrollend"))); + expect(scroller.style.scrollSnapType).toBe("x proximity"); + + act(() => scroller.dispatchEvent(new Event("pointerdown"))); + expect(scroller.style.scrollSnapType).toBe("none"); + unmount(); + expect(scroller.style.scrollSnapType).toBe("x proximity"); }); it("attaches after a loading skeleton is replaced by the live board", () => { @@ -107,13 +136,27 @@ describe("useColumnScrollSnap", () => { const scroller = createScroller(); renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + act(() => scroller.dispatchEvent(new Event("pointerdown"))); + expect(scroller.style.scrollSnapType).toBe("none"); act(() => { - scroller.dispatchEvent(new Event("pointerdown")); - scroller.dispatchEvent(new Event("scrollend")); + scroller.dispatchEvent(new Event("pointerup")); vi.advanceTimersByTime(500); }); expect(scroller.scrollTo).not.toHaveBeenCalled(); + expect(scroller.style.scrollSnapType).toBe(""); + }); + + it("restores native proximity after a wheel that produces no horizontal scroll", () => { + const scroller = createScroller(); + renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => scroller.dispatchEvent(new Event("wheel"))); + expect(scroller.style.scrollSnapType).toBe("none"); + act(() => vi.advanceTimersByTime(120)); + + expect(scroller.scrollTo).not.toHaveBeenCalled(); + expect(scroller.style.scrollSnapType).toBe(""); }); it.each([0, 1])("does nothing with %s snap children", (columnCount) => { @@ -123,6 +166,7 @@ describe("useColumnScrollSnap", () => { act(() => dispatchUserPan(scroller)); expect(scroller.scrollTo).not.toHaveBeenCalled(); + expect(scroller.style.scrollSnapType).toBe(""); }); it("does not attach magnetic snapping on a wide, short non-phone desktop", () => { @@ -140,6 +184,7 @@ describe("useColumnScrollSnap", () => { act(() => dispatchUserPan(scroller)); expect(addListener).not.toHaveBeenCalledWith("scrollend", expect.any(Function)); + expect(scroller.style.scrollSnapType).toBe(""); expect(scroller.scrollTo).not.toHaveBeenCalled(); }); diff --git a/packages/dashboard/app/hooks/useColumnScrollSnap.ts b/packages/dashboard/app/hooks/useColumnScrollSnap.ts index 33dab89131..ebd12cc403 100644 --- a/packages/dashboard/app/hooks/useColumnScrollSnap.ts +++ b/packages/dashboard/app/hooks/useColumnScrollSnap.ts @@ -33,6 +33,12 @@ function addMediaChangeListener(query: MediaQueryList, listener: () => void): () * regressed FN-001 by snapping against stale iOS layout metrics. Preserve proximity CSS and * snap only after verified user horizontal movement has ended; mount, reflow, resize, pageshow, * and programmatic scrolling must never choose a board column. + * + * FNXC:BoardNavigation 2026-07-16-08:35: + * Issue #2245 / #2303 unifies the native `x proximity` drag behavior and JS scroll-end drop + * behavior by suspending native snap only during a verified user pan. The hook then owns the + * one-column resolution and restores the prior inline value; `x mandatory` remains prohibited + * to preserve the FN-001 corner-rendering fix. */ export function useColumnScrollSnap( scroller: HTMLElement | null, @@ -71,6 +77,8 @@ export function useColumnScrollSnap( let interactionScrollLeft = scroller.scrollLeft; let sawHorizontalMovement = false; let isSnapping = false; + let nativeSnapSuspended = false; + let priorInlineScrollSnapType = ""; let idleTimer: ReturnType | null = null; let snapReleaseTimer: ReturnType | null = null; @@ -79,26 +87,56 @@ export function useColumnScrollSnap( idleTimer = null; }; + const restoreNativeSnap = () => { + if (!nativeSnapSuspended) return; + scroller.style.scrollSnapType = priorInlineScrollSnapType; + nativeSnapSuspended = false; + }; + + const suspendNativeSnap = () => { + if (nativeSnapSuspended) return; + priorInlineScrollSnapType = scroller.style.scrollSnapType; + scroller.style.scrollSnapType = "none"; + nativeSnapSuspended = true; + }; + + const finishWithoutSnap = () => { + interactionActive = false; + sawHorizontalMovement = false; + restoreNativeSnap(); + }; + const releaseSnap = () => { if (snapReleaseTimer !== null) clearTimeout(snapReleaseTimer); snapReleaseTimer = setTimeout(() => { isSnapping = false; snapReleaseTimer = null; + restoreNativeSnap(); }, SNAP_RELEASE_DELAY_MS); }; const snapToNearestColumn = () => { clearIdleTimer(); - if (!interactionActive || !sawHorizontalMovement || isSnapping) return; + if (isSnapping) return; + if (!interactionActive || !sawHorizontalMovement) { + if (interactionActive) finishWithoutSnap(); + return; + } interactionActive = false; sawHorizontalMovement = false; const columns = Array.from(scroller.children) as HTMLElement[]; - if (columns.length < 2) return; + if (columns.length < 2) { + restoreNativeSnap(); + return; + } const scrollerRect = scroller.getBoundingClientRect(); const viewportWidth = scroller.clientWidth || scrollerRect.width; - if (viewportWidth <= 0) return; + if (viewportWidth <= 0) { + restoreNativeSnap(); + return; + } const viewportCenter = scrollerRect.left + viewportWidth / 2; let nearestColumn: HTMLElement | null = null; @@ -111,7 +149,10 @@ export function useColumnScrollSnap( nearestDistance = distance; } } - if (!nearestColumn || nearestDistance <= CENTER_TOLERANCE_PX) return; + if (!nearestColumn || nearestDistance <= CENTER_TOLERANCE_PX) { + restoreNativeSnap(); + return; + } const columnRect = nearestColumn.getBoundingClientRect(); const targetLeft = scroller.scrollLeft + columnRect.left + columnRect.width / 2 - viewportCenter; @@ -129,6 +170,14 @@ export function useColumnScrollSnap( interactionActive = true; sawHorizontalMovement = false; interactionScrollLeft = scroller.scrollLeft; + suspendNativeSnap(); + // FNXC:BoardNavigation 2026-07-18-09:03: Wheel input has no end event. Arm the same idle + // settlement path immediately so vertical and boundary wheels that produce no horizontal + // scroll restore the proximity baseline instead of leaving native snapping disabled. + if (event.type === "wheel") { + clearIdleTimer(); + idleTimer = setTimeout(snapToNearestColumn, SCROLL_IDLE_DELAY_MS); + } }; const handleScroll = () => { @@ -141,7 +190,12 @@ export function useColumnScrollSnap( }; const handleInteractionEnd = () => { - if (!interactionActive || !sawHorizontalMovement || isSnapping) return; + if (!interactionActive || isSnapping) return; + if (!sawHorizontalMovement) { + clearIdleTimer(); + finishWithoutSnap(); + return; + } clearIdleTimer(); idleTimer = setTimeout(snapToNearestColumn, SCROLL_IDLE_DELAY_MS); }; @@ -151,6 +205,7 @@ export function useColumnScrollSnap( isSnapping = false; if (snapReleaseTimer !== null) clearTimeout(snapReleaseTimer); snapReleaseTimer = null; + restoreNativeSnap(); return; } snapToNearestColumn(); @@ -167,6 +222,7 @@ export function useColumnScrollSnap( return () => { clearIdleTimer(); if (snapReleaseTimer !== null) clearTimeout(snapReleaseTimer); + restoreNativeSnap(); scroller.removeEventListener("pointerdown", beginInteraction); scroller.removeEventListener("touchstart", beginInteraction); scroller.removeEventListener("wheel", beginInteraction);