FN-8489: hard-settle mobile board column swipes

Ensure mobile board pans never stop between columns.

- Detect centered columns before resolving a zero-direction pan
- Snap off-center zero-direction pans to the nearest column center
- Add regression coverage and document the mobile settle behavior

Files changed:
 .changeset/fn-8489-mobile-column-settle.md         |  7 +++++
 docs/dashboard-guide.md                            |  2 +-
 .../app/components/__tests__/board-mobile.test.tsx |  4 +--
 .../hooks/__tests__/useColumnScrollSnap.test.ts    | 32 ++++++++++++++++++++++
 .../dashboard/app/hooks/useColumnScrollSnap.ts     | 29 ++++++++++++++++++--
 5 files changed, 69 insertions(+), 5 deletions(-)

Fusion-Task-Id: FN-8489

Fusion-Task-Lineage: 555bd906-0ac8-4c43-a00b-24d1e716488a

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-22 09:17:14 -07:00
parent f937482b76
commit 57af2490fe
5 changed files with 69 additions and 5 deletions

View File

@@ -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).

View File

@@ -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.
<!-- FNXC:BoardNavigationDocs 2026-07-16-08:35: Issue #2245 / #2303 requires one mobile board magnetism: suspend native proximity during a user pan, let JS resolve exactly one centered column at drag-end, then restore proximity without using prohibited x mandatory so FN-001 corner rendering remains intact. -->
<!-- FNXC:BoardNavigationDocs 2026-07-15-13:30: Mobile Kanban documentation must describe the user-only JS scroll-end snap and its FN-001 proximity-CSS rationale so operators understand why layout changes never force a column. -->

View File

@@ -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", () => {

View File

@@ -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 }));

View File

@@ -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);
};