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:
7
.changeset/fn-010-smooth-mobile-board-release.md
Normal file
7
.changeset/fn-010-smooth-mobile-board-release.md
Normal 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.
|
||||
@@ -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. -->
|
||||
|
||||
@@ -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}>');
|
||||
});
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
Reference in New Issue
Block a user