FN-010: animate mobile board column releases

Smooth mobile Board releases now settle continuously on the existing reachable column target.

- Animate directional and nearest-column corrections with safe immediate fallbacks.
- Cancel in-flight settling and restore styles on unmount while preserving snap wiring.
- Update regression coverage, mobile board documentation, and the published fix changeset.

Files changed:
 .changeset/fn-010-smooth-mobile-board-release.md   |   7 ++
 docs/dashboard-guide.md                            |   5 +-
 .../app/components/__tests__/board-mobile.test.tsx |   3 +-
 .../hooks/__tests__/useColumnScrollSnap.test.ts    | 109 +++++++++++++++++++--
 .../dashboard/app/hooks/useColumnScrollSnap.ts     |  46 ++++++---
 5 files changed, 146 insertions(+), 24 deletions(-)

Fusion-Task-Id: FN-010

Fusion-Task-Lineage: 16d3810b-8498-4436-8c41-12676778408a

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-18 19:35:33 +00:00
parent c380e680a8
commit c0966fb3c2
5 changed files with 146 additions and 24 deletions

View File

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

View File

@@ -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.
<!-- FNXC:BoardNavigationDocs 2026-08-18-19:10: Phone releases keep free finger scrolling, then use one smooth controlled normal-motion settle to the already-valid reachable column. Reduced-motion users still receive an immediate landing, while programmatic, refresh, resize, and restored-page scrolls remain unsnapped. -->
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.
<!-- 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

@@ -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 `<main className="board">` render is gone; assert it stays gone.
expect(boardSource).not.toContain('<main className="board" id="board" ref={setBoardRef}>');
});

View File

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

View File

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