From 4efa7b51c6be5360a49ecfe78cd88661f3b310f7 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 22 Jul 2026 08:32:28 -0700 Subject: [PATCH] fix(dashboard): harden mobile board column snap direction and settle Lock snap direction at finger-up from the net swipe, page only in that scroll direction, hard-jump and pin until the next touch so residual fling and CSS proximity no longer drift after settle. --- docs/dashboard-guide.md | 2 +- .../__tests__/useColumnScrollSnap.test.ts | 327 ++++++++----- .../app/hooks/useColumnScrollSnap.ts | 444 ++++++++++++++---- packages/dashboard/app/styles.css | 8 +- 4 files changed, 574 insertions(+), 207 deletions(-) diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 23278a0a21..2c6b32d36e 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -100,7 +100,7 @@ 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. 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. 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. diff --git a/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts b/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts index 3a9456ad22..106b546d1d 100644 --- a/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts @@ -1,14 +1,25 @@ import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useColumnScrollSnap } from "../useColumnScrollSnap"; +import { + resolvePanDirection, + resolveTargetIndexInScrollDirection, + useColumnScrollSnap, +} from "../useColumnScrollSnap"; import { isMobileViewport } from "../useViewportMode"; type Viewport = "mobile" | "wide-short-desktop"; +const COLUMN_WIDTH = 100; + function stubViewport(viewport: Viewport): void { const isMobile = viewport === "mobile"; vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ - matches: query === "(max-width: 768px)" ? isMobile : query === "(max-height: 480px)", + matches: + query === "(max-width: 768px)" + ? isMobile + : query === "(max-height: 480px)" + ? true + : false, media: query, onchange: null, addEventListener: vi.fn(), @@ -30,28 +41,111 @@ function stubViewport(viewport: Viewport): void { }); } -function createScroller(columnCount = 2): HTMLElement { +function createScroller(columnCount = 3, initialScrollLeft = 0): HTMLElement { const scroller = document.createElement("main"); - Object.defineProperty(scroller, "clientWidth", { configurable: true, value: 100 }); - scroller.getBoundingClientRect = () => new DOMRect(0, 0, 100, 200); - Object.defineProperty(scroller, "scrollTo", { configurable: true, value: vi.fn() }); + Object.defineProperty(scroller, "clientWidth", { configurable: true, value: COLUMN_WIDTH }); + scroller.getBoundingClientRect = () => new DOMRect(0, 0, COLUMN_WIDTH, 200); + let scrollLeft = initialScrollLeft; + Object.defineProperty(scroller, "scrollLeft", { + configurable: true, + get: () => scrollLeft, + set: (value: number) => { + scrollLeft = value; + }, + }); + scroller.setPointerCapture = vi.fn(); + scroller.releasePointerCapture = vi.fn(); + scroller.hasPointerCapture = vi.fn(() => false); for (let index = 0; index < columnCount; index++) { const column = document.createElement("section"); - const left = index === 0 ? -60 : 30; - column.getBoundingClientRect = () => new DOMRect(left, 0, 100, 200); + column.className = "column"; + column.getBoundingClientRect = () => { + const left = index * COLUMN_WIDTH - scrollLeft; + return new DOMRect(left, 0, COLUMN_WIDTH, 200); + }; scroller.append(column); } document.body.append(scroller); return scroller; } -function dispatchUserPan(scroller: HTMLElement): void { - scroller.dispatchEvent(new Event("pointerdown")); - scroller.scrollLeft = 10; - scroller.dispatchEvent(new Event("scroll")); - scroller.dispatchEvent(new Event("scrollend")); +function dispatchPointerEvent(scroller: HTMLElement, type: string, clientX: number): void { + scroller.dispatchEvent( + new PointerEvent(type, { clientX, pointerId: 1, isPrimary: true, bubbles: true, cancelable: true }), + ); } +function dispatchShortSwipe( + scroller: HTMLElement, + options: { scrollDelta?: number; clientDelta?: number }, +): void { + const scrollDelta = options.scrollDelta ?? 4; + const clientDelta = options.clientDelta ?? 24; + scroller.dispatchEvent(new Event("touchstart")); + dispatchPointerEvent(scroller, "pointerdown", 200); + dispatchPointerEvent(scroller, "pointermove", 200 - clientDelta); + scroller.scrollLeft = scroller.scrollLeft + scrollDelta; + scroller.dispatchEvent(new Event("scroll")); + dispatchPointerEvent(scroller, "pointerup", 200 - clientDelta); +} + +function settleAfterMomentum(): void { + act(() => { + vi.advanceTimersByTime(48); + }); +} + +describe("resolvePanDirection", () => { + it("uses net scroll delta only (not micro-ticks)", () => { + expect(resolvePanDirection({ scrollDelta: 5, clientDelta: 0 })).toBe(1); + expect(resolvePanDirection({ scrollDelta: -5, clientDelta: 0 })).toBe(-1); + }); + + it("uses finger travel when scroll barely moved", () => { + expect(resolvePanDirection({ scrollDelta: 0, clientDelta: 12 })).toBe(1); + expect(resolvePanDirection({ scrollDelta: 0, clientDelta: -12 })).toBe(-1); + }); + + it("ignores tiny noise", () => { + expect(resolvePanDirection({ scrollDelta: 0, clientDelta: 3 })).toBe(0); + }); +}); + +describe("resolveTargetIndexInScrollDirection", () => { + it("forward from column 0 always goes right to column 1", () => { + const scroller = createScroller(3, 0); + expect(resolveTargetIndexInScrollDirection(scroller, [...scroller.children] as HTMLElement[], 1)).toBe(1); + }); + + it("forward past column 0 center still goes right, never left", () => { + const scroller = createScroller(3, 40); + // nearest may be 0; past its center → 1 + expect(resolveTargetIndexInScrollDirection(scroller, [...scroller.children] as HTMLElement[], 1)).toBe(1); + }); + + it("forward when nearest is already column 1 stays on 1 if still approaching its center", () => { + const scroller = createScroller(3, 80); + const columns = [...scroller.children] as HTMLElement[]; + const index = resolveTargetIndexInScrollDirection(scroller, columns, 1); + expect(index).toBeGreaterThanOrEqual(1); + expect(index).toBeLessThanOrEqual(2); + }); + + it("back from column 1 always goes left to column 0", () => { + const scroller = createScroller(3, COLUMN_WIDTH); + expect(resolveTargetIndexInScrollDirection(scroller, [...scroller.children] as HTMLElement[], -1)).toBe(0); + }); + + it("never returns a column against scroll direction from nearest", () => { + const scroller = createScroller(4, COLUMN_WIDTH); + const columns = [...scroller.children] as HTMLElement[]; + // At col 1, scroll right → not 0 + expect(resolveTargetIndexInScrollDirection(scroller, columns, 1)).toBeGreaterThanOrEqual(1); + // At col 1, scroll left → not 2+ + expect(resolveTargetIndexInScrollDirection(scroller, columns, -1)).toBeLessThanOrEqual(1); + }); +}); + describe("useColumnScrollSnap", () => { beforeEach(() => { vi.useFakeTimers(); @@ -65,140 +159,155 @@ describe("useColumnScrollSnap", () => { vi.restoreAllMocks(); }); - it("unifies a user pan into one JS snap and restores the CSS proximity baseline", () => { + it("forward short swipe snaps to the next column on the right", () => { + const scroller = createScroller(3, 0); + renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => dispatchShortSwipe(scroller, { scrollDelta: 8, clientDelta: 20 })); + settleAfterMomentum(); + + expect(scroller.scrollLeft).toBe(COLUMN_WIDTH); + }); + + it("does not reverse direction when post-lift scroll rubber-bands", () => { + const scroller = createScroller(3, 0); + renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => { + dispatchPointerEvent(scroller, "pointerdown", 200); + dispatchPointerEvent(scroller, "pointermove", 160); + scroller.scrollLeft = 30; + scroller.dispatchEvent(new Event("scroll")); + dispatchPointerEvent(scroller, "pointerup", 160); + // Simulated fling end bounce left (wrong-way micro ticks after lift). + scroller.scrollLeft = 28; + scroller.dispatchEvent(new Event("scroll")); + scroller.scrollLeft = 25; + scroller.dispatchEvent(new Event("scroll")); + }); + settleAfterMomentum(); + + // Must still land on the next column to the right, not snap back to 0. + expect(scroller.scrollLeft).toBe(COLUMN_WIDTH); + }); + + it("backward short swipe snaps to the previous column on the left", () => { + 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); + }); + settleAfterMomentum(); + + expect(scroller.scrollLeft).toBe(0); + }); + + it("free-scrolls while dragging and coasts after lift before snapping", () => { const scroller = createScroller(); renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); - act(() => scroller.dispatchEvent(new Event("pointerdown"))); - expect(scroller.style.scrollSnapType).toBe("none"); + act(() => { + dispatchPointerEvent(scroller, "pointerdown", 200); + scroller.scrollLeft = 40; + scroller.dispatchEvent(new Event("scroll")); + dispatchPointerEvent(scroller, "pointermove", 160); + dispatchPointerEvent(scroller, "pointerup", 160); + }); + expect(scroller.scrollLeft).toBe(40); act(() => { - scroller.scrollLeft = 10; + scroller.scrollLeft = 70; + scroller.dispatchEvent(new Event("scroll")); + }); + expect(scroller.scrollLeft).toBe(70); + + settleAfterMomentum(); + expect(scroller.scrollLeft).toBe(COLUMN_WIDTH); + }); + + it("pins after settle so residual fling cannot move the board", () => { + const scroller = createScroller(); + renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => dispatchShortSwipe(scroller, { scrollDelta: 10, clientDelta: 20 })); + settleAfterMomentum(); + expect(scroller.scrollLeft).toBe(COLUMN_WIDTH); + + act(() => { + scroller.scrollLeft = COLUMN_WIDTH + 40; scroller.dispatchEvent(new Event("scroll")); scroller.dispatchEvent(new Event("scrollend")); + vi.advanceTimersByTime(500); }); - - 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); + expect(scroller.scrollLeft).toBe(COLUMN_WIDTH); }); - 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", () => { - const scroller = createScroller(); - const { rerender } = renderHook( - ({ element }) => useColumnScrollSnap(element, { mobileOnly: true, isUserInteraction: () => true }), - { initialProps: { element: null as HTMLElement | null } }, - ); - - rerender({ element: scroller }); - act(() => dispatchUserPan(scroller)); - - expect(scroller.scrollTo).toHaveBeenCalledWith({ left: 40, behavior: "smooth" }); - }); - - it("does not snap on mount, viewport lifecycle events, or programmatic scrolling", () => { + it("does not snap on touchcancel mid-drag", () => { + const scroller = createScroller(); + renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => { + dispatchPointerEvent(scroller, "pointerdown", 200); + dispatchPointerEvent(scroller, "pointermove", 170); + scroller.scrollLeft = 25; + scroller.dispatchEvent(new Event("scroll")); + scroller.dispatchEvent(new Event("touchcancel")); + vi.advanceTimersByTime(30); + }); + expect(scroller.scrollLeft).toBe(25); + + settleAfterMomentum(); + expect(scroller.scrollLeft).toBe(COLUMN_WIDTH); + }); + + it("does not snap on mount or programmatic scrolling", () => { const scroller = createScroller(); renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); act(() => { - window.dispatchEvent(new Event("resize")); - window.dispatchEvent(new Event("pageshow")); scroller.scrollLeft = 40; scroller.dispatchEvent(new Event("scroll")); scroller.dispatchEvent(new Event("scrollend")); vi.advanceTimersByTime(500); }); - - expect(scroller.scrollTo).not.toHaveBeenCalled(); + expect(scroller.scrollLeft).toBe(40); }); - it("requires horizontal movement after user input rather than a recent tap", () => { + it("requires horizontal movement rather than a tap", () => { 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("pointerup")); + dispatchPointerEvent(scroller, "pointerdown", 100); + dispatchPointerEvent(scroller, "pointerup", 100); vi.advanceTimersByTime(500); }); - - expect(scroller.scrollTo).not.toHaveBeenCalled(); - expect(scroller.style.scrollSnapType).toBe(""); + expect(scroller.scrollLeft).toBe(0); }); - 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) => { - const scroller = createScroller(columnCount); - renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); - - 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", () => { + it("does not attach on non-phone desktop", () => { stubViewport("wide-short-desktop"); - expect(window.screen.width).toBe(1920); - expect(window.screen.height).toBe(1080); - expect(window.visualViewport?.width).toBe(1200); - expect(window.matchMedia("(max-width: 768px)").matches).toBe(false); - expect(window.matchMedia("(max-height: 480px)").matches).toBe(true); expect(isMobileViewport()).toBe(false); const scroller = createScroller(); const addListener = vi.spyOn(scroller, "addEventListener"); renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); - act(() => dispatchUserPan(scroller)); - - expect(addListener).not.toHaveBeenCalledWith("scrollend", expect.any(Function)); - expect(scroller.style.scrollSnapType).toBe(""); - expect(scroller.scrollTo).not.toHaveBeenCalled(); + act(() => dispatchShortSwipe(scroller, { scrollDelta: 10, clientDelta: 20 })); + expect(addListener).not.toHaveBeenCalledWith("pointerup", expect.any(Function)); + expect(scroller.scrollLeft).toBe(10); }); - it("ignores scroll activity while its own smooth snap is in progress", () => { - const scroller = createScroller(); + it.each([0, 1])("does nothing with %s columns", (columnCount) => { + const scroller = createScroller(columnCount); renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); - act(() => { - dispatchUserPan(scroller); - scroller.dispatchEvent(new Event("scroll")); - scroller.dispatchEvent(new Event("scrollend")); - dispatchUserPan(scroller); - }); - - expect(scroller.scrollTo).toHaveBeenCalledTimes(1); + act(() => dispatchShortSwipe(scroller, { scrollDelta: 10, clientDelta: 20 })); + settleAfterMomentum(); + expect(scroller.scrollLeft).toBe(10); }); }); diff --git a/packages/dashboard/app/hooks/useColumnScrollSnap.ts b/packages/dashboard/app/hooks/useColumnScrollSnap.ts index ebd12cc403..22ddce5f54 100644 --- a/packages/dashboard/app/hooks/useColumnScrollSnap.ts +++ b/packages/dashboard/app/hooks/useColumnScrollSnap.ts @@ -1,9 +1,19 @@ import { useEffect, useState } from "react"; import { isMobileViewport } from "./useViewportMode"; -const SCROLL_IDLE_DELAY_MS = 120; -const SNAP_RELEASE_DELAY_MS = 300; +/* +FNXC:BoardNavigation 2026-07-22-18:00: +Wrong-way snaps came from (1) settle direction using the last micro scroll tick — iOS +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. +*/ +/** After lift/cancel/wheel: wait for scroll idle (momentum finished) before paging. */ +const SCROLL_IDLE_SETTLE_MS = 48; const CENTER_TOLERANCE_PX = 1; +/** Minimum finger travel to count as a horizontal pan (short swipe still commits). */ +const MIN_PAN_CLIENT_PX = 12; export interface UseColumnScrollSnapOptions { /** Restrict magnetic snapping to phone-class viewports. */ @@ -25,20 +35,144 @@ function addMediaChangeListener(query: MediaQueryList, listener: () => void): () return () => query.removeListener(listener); } +function getClientX(event: Event): number | null { + if (typeof TouchEvent !== "undefined" && event instanceof TouchEvent) { + const touch = event.touches[0] ?? event.changedTouches[0]; + return touch ? touch.clientX : null; + } + if ("clientX" in event && typeof (event as PointerEvent).clientX === "number") { + return (event as PointerEvent).clientX; + } + return null; +} + +/** Prefer `.column` children so spacers/chrome are not snap targets. */ +export function getSnapColumns(scroller: HTMLElement): HTMLElement[] { + const all = Array.from(scroller.children).filter( + (node): node is HTMLElement => node instanceof HTMLElement, + ); + const columns = all.filter((el) => el.classList.contains("column")); + return columns.length >= 2 ? columns : all; +} + +/** Index of the column whose center is closest to the scroller viewport center. */ +export function nearestColumnIndex(scroller: HTMLElement, columns: HTMLElement[]): number { + const scrollerRect = scroller.getBoundingClientRect(); + const viewportWidth = scroller.clientWidth || scrollerRect.width; + if (viewportWidth <= 0 || columns.length === 0) return 0; + + const viewportCenter = scrollerRect.left + viewportWidth / 2; + let nearestIndex = 0; + let nearestDistance = Number.POSITIVE_INFINITY; + for (let index = 0; index < columns.length; index++) { + const rect = columns[index].getBoundingClientRect(); + const distance = Math.abs(rect.left + rect.width / 2 - viewportCenter); + if (distance < nearestDistance) { + nearestIndex = index; + nearestDistance = distance; + } + } + return nearestIndex; +} + +/** scrollLeft that centers `column` in the scroller viewport (integer pixels). */ +function scrollLeftToCenterColumn(scroller: HTMLElement, column: HTMLElement): number { + const scrollerRect = scroller.getBoundingClientRect(); + const viewportWidth = scroller.clientWidth || scrollerRect.width; + const viewportCenter = scrollerRect.left + viewportWidth / 2; + const columnRect = column.getBoundingClientRect(); + return Math.round(scroller.scrollLeft + columnRect.left + columnRect.width / 2 - viewportCenter); +} + /** - * Adds a mobile-only, user-driven scroll-end snap to a horizontal column scroller. + * Resolve pan direction from the full gesture (net deltas only). + * Do NOT pass last micro-tick direction for settle — rubber-band flips it. + * +1 = scroll right / next columns, -1 = scroll left / previous. + */ +export function resolvePanDirection(options: { + scrollDelta: number; + /** gestureStartClientX - endClientX: finger left → positive → next column */ + clientDelta: number; +}): number { + const { scrollDelta, clientDelta } = options; + if (scrollDelta > CENTER_TOLERANCE_PX) return 1; + if (scrollDelta < -CENTER_TOLERANCE_PX) return -1; + if (clientDelta >= MIN_PAN_CLIENT_PX) return 1; + if (clientDelta <= -MIN_PAN_CLIENT_PX) return -1; + return 0; +} + +/** + * Pick the column to land on given locked scroll direction and current viewport. + * Always in the scroll direction — never the opposite column. * - * FNXC:BoardNavigation 2026-07-15-13:30: - * Mobile Kanban swipes must settle on one column, but CSS `scroll-snap-type: x mandatory` - * 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. + * Moving right (dir +1): if still approaching nearest from the left, land on nearest; + * otherwise land on nearest+1 (the next column on the right). + * Moving left (dir -1): mirror. + */ +export function resolveTargetIndexInScrollDirection( + scroller: HTMLElement, + columns: HTMLElement[], + direction: number, +): number { + if (columns.length <= 1) return 0; + const nearest = nearestColumnIndex(scroller, columns); + if (direction === 0) return nearest; + + const scrollerRect = scroller.getBoundingClientRect(); + const viewportWidth = scroller.clientWidth || scrollerRect.width; + const viewportCenter = scrollerRect.left + viewportWidth / 2; + const nearestRect = columns[nearest].getBoundingClientRect(); + const nearestCenter = nearestRect.left + nearestRect.width / 2; + + if (direction > 0) { + // Content scrolling right: next column on the right of travel. + if (viewportCenter + CENTER_TOLERANCE_PX < nearestCenter) { + return nearest; + } + return Math.min(columns.length - 1, nearest + 1); + } + + // Content scrolling left: next column on the left of travel. + if (viewportCenter - CENTER_TOLERANCE_PX > nearestCenter) { + return nearest; + } + return Math.max(0, nearest - 1); +} + +/** + * Kill residual scroll inertia and jump to an integer scrollLeft. + */ +function hardJumpScrollLeft(scroller: HTMLElement, targetLeft: number): void { + const target = Math.round(targetLeft); + const priorOverflowX = scroller.style.overflowX; + const priorBehavior = scroller.style.scrollBehavior; + const priorWebkit = scroller.style.getPropertyValue("-webkit-overflow-scrolling"); + + scroller.style.scrollBehavior = "auto"; + scroller.style.scrollSnapType = "none"; + scroller.style.overflowX = "hidden"; + scroller.style.setProperty("-webkit-overflow-scrolling", "auto"); + scroller.scrollLeft = target; + void scroller.offsetWidth; + scroller.scrollLeft = target; + + scroller.style.overflowX = priorOverflowX; + scroller.style.scrollBehavior = priorBehavior; + if (priorWebkit) { + scroller.style.setProperty("-webkit-overflow-scrolling", priorWebkit); + } else { + scroller.style.removeProperty("-webkit-overflow-scrolling"); + } + scroller.scrollLeft = target; +} + +/** + * Mobile board: free-scroll + momentum, then hard-page only in the scroll direction. * - * 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. + * FNXC:BoardNavigation 2026-07-22-18:00: + * Lock settle direction at finger-up from net gesture deltas. Target via + * resolveTargetIndexInScrollDirection so snap never goes against scroll. Pin until next touch. */ export function useColumnScrollSnap( scroller: HTMLElement | null, @@ -74,13 +208,20 @@ export function useColumnScrollSnap( if (!scroller || !isEligibleViewport) return; let interactionActive = false; - let interactionScrollLeft = scroller.scrollLeft; + let pointerHeld = false; + let gestureStartScrollLeft = scroller.scrollLeft; + let lastScrollLeft = scroller.scrollLeft; + let gestureStartClientX: number | null = null; + let lastClientX: number | null = null; + /** Locked at finger-up / cancel — never updated by post-lift rubber-band ticks. */ + let lockedDirection = 0; let sawHorizontalMovement = false; - let isSnapping = false; let nativeSnapSuspended = false; let priorInlineScrollSnapType = ""; let idleTimer: ReturnType | null = null; - let snapReleaseTimer: ReturnType | null = null; + let capturedPointerId: number | null = null; + /** Force scrollLeft until the next user touch. */ + let pinnedScrollLeft: number | null = null; const clearIdleTimer = () => { if (idleTimer !== null) clearTimeout(idleTimer); @@ -100,136 +241,247 @@ export function useColumnScrollSnap( nativeSnapSuspended = true; }; - const finishWithoutSnap = () => { + const releasePointerCapture = () => { + if (capturedPointerId === null) return; + try { + if (scroller.hasPointerCapture?.(capturedPointerId)) { + scroller.releasePointerCapture(capturedPointerId); + } + } catch { + // already released + } + capturedPointerId = null; + }; + + const clearPin = () => { + pinnedScrollLeft = null; + }; + + /** + * Freeze direction from the whole gesture (net scroll + finger travel). + * Called once at lift/cancel — not on later scroll ticks. + */ + const lockDirectionFromGesture = () => { + const scrollDelta = scroller.scrollLeft - gestureStartScrollLeft; + const clientDelta = + gestureStartClientX !== null && lastClientX !== null + ? gestureStartClientX - lastClientX + : 0; + lockedDirection = resolvePanDirection({ scrollDelta, clientDelta }); + }; + + const applySnapTo = (targetLeft: number) => { + const target = Math.round(targetLeft); + pointerHeld = false; + suspendNativeSnap(); + hardJumpScrollLeft(scroller, target); + pinnedScrollLeft = target; + scroller.scrollLeft = target; + }; + + const snapInScrollDirection = () => { + clearIdleTimer(); + if (!interactionActive) return; + if (pointerHeld) return; + + const scrollDelta = scroller.scrollLeft - gestureStartScrollLeft; + const clientDelta = + gestureStartClientX !== null && lastClientX !== null + ? gestureStartClientX - lastClientX + : 0; + + // Prefer direction locked at lift; recompute only if never locked. + const direction = + lockedDirection !== 0 + ? lockedDirection + : resolvePanDirection({ scrollDelta, clientDelta }); + + const hadPanIntent = + sawHorizontalMovement || + Math.abs(scrollDelta) > CENTER_TOLERANCE_PX || + Math.abs(clientDelta) >= MIN_PAN_CLIENT_PX; + interactionActive = false; sawHorizontalMovement = false; - restoreNativeSnap(); - }; + lockedDirection = 0; + gestureStartClientX = null; + lastClientX = null; - const releaseSnap = () => { - if (snapReleaseTimer !== null) clearTimeout(snapReleaseTimer); - snapReleaseTimer = setTimeout(() => { - isSnapping = false; - snapReleaseTimer = null; + if (!hadPanIntent || direction === 0) { restoreNativeSnap(); - }, SNAP_RELEASE_DELAY_MS); - }; - - const snapToNearestColumn = () => { - clearIdleTimer(); - if (isSnapping) return; - if (!interactionActive || !sawHorizontalMovement) { - if (interactionActive) finishWithoutSnap(); return; } - interactionActive = false; - sawHorizontalMovement = false; - const columns = Array.from(scroller.children) as HTMLElement[]; + const columns = getSnapColumns(scroller); if (columns.length < 2) { restoreNativeSnap(); return; } - const scrollerRect = scroller.getBoundingClientRect(); - const viewportWidth = scroller.clientWidth || scrollerRect.width; + const viewportWidth = scroller.clientWidth || scroller.getBoundingClientRect().width; if (viewportWidth <= 0) { restoreNativeSnap(); return; } - const viewportCenter = scrollerRect.left + viewportWidth / 2; - let nearestColumn: HTMLElement | null = null; - let nearestDistance = Number.POSITIVE_INFINITY; - for (const column of columns) { - const rect = column.getBoundingClientRect(); - const distance = Math.abs(rect.left + rect.width / 2 - viewportCenter); - if (distance < nearestDistance) { - nearestColumn = column; - nearestDistance = distance; - } - } - if (!nearestColumn || nearestDistance <= CENTER_TOLERANCE_PX) { - restoreNativeSnap(); - return; - } + const targetIndex = resolveTargetIndexInScrollDirection(scroller, columns, direction); + const targetLeft = scrollLeftToCenterColumn(scroller, columns[targetIndex]); + applySnapTo(targetLeft); + }; - const columnRect = nearestColumn.getBoundingClientRect(); - const targetLeft = scroller.scrollLeft + columnRect.left + columnRect.width / 2 - viewportCenter; - isSnapping = true; - if (typeof scroller.scrollTo === "function") { - scroller.scrollTo({ left: targetLeft, behavior: "smooth" }); - } else { - scroller.scrollLeft = targetLeft; - } - releaseSnap(); + const armIdleSettle = () => { + clearIdleTimer(); + idleTimer = setTimeout(snapInScrollDirection, SCROLL_IDLE_SETTLE_MS); }; const beginInteraction = (event: Event) => { - if (isSnapping || !isUserInteraction(event)) return; + if (!isUserInteraction(event)) return; + + clearPin(); + + if (interactionActive) { + if (event.type === "pointerdown" && "pointerId" in event) { + try { + scroller.setPointerCapture((event as PointerEvent).pointerId); + capturedPointerId = (event as PointerEvent).pointerId; + } catch { + // ignore + } + } + return; + } + 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. + lockedDirection = 0; + gestureStartScrollLeft = scroller.scrollLeft; + lastScrollLeft = scroller.scrollLeft; + const clientX = getClientX(event); + gestureStartClientX = clientX; + lastClientX = clientX; + if (event.type === "wheel") { - clearIdleTimer(); - idleTimer = setTimeout(snapToNearestColumn, SCROLL_IDLE_DELAY_MS); + pointerHeld = false; + suspendNativeSnap(); + armIdleSettle(); + return; + } + + pointerHeld = true; + if (event.type === "pointerdown" && "pointerId" in event) { + try { + scroller.setPointerCapture((event as PointerEvent).pointerId); + capturedPointerId = (event as PointerEvent).pointerId; + } catch { + // ignore + } + } + }; + + const markMoved = () => { + if (!sawHorizontalMovement) { + suspendNativeSnap(); + } + sawHorizontalMovement = true; + }; + + const handlePointerMove = (event: Event) => { + if (!interactionActive || pinnedScrollLeft !== null) return; + const clientX = getClientX(event); + if (clientX === null) return; + lastClientX = clientX; + if ( + gestureStartClientX !== null && + Math.abs(gestureStartClientX - clientX) >= MIN_PAN_CLIENT_PX + ) { + markMoved(); } }; const handleScroll = () => { - if (isSnapping || !interactionActive) return; - if (scroller.scrollLeft === interactionScrollLeft) return; - sawHorizontalMovement = true; - interactionScrollLeft = scroller.scrollLeft; - clearIdleTimer(); - idleTimer = setTimeout(snapToNearestColumn, SCROLL_IDLE_DELAY_MS); - }; - - const handleInteractionEnd = () => { - if (!interactionActive || isSnapping) return; - if (!sawHorizontalMovement) { - clearIdleTimer(); - finishWithoutSnap(); + if (pinnedScrollLeft !== null) { + scroller.scrollLeft = pinnedScrollLeft; return; } - clearIdleTimer(); - idleTimer = setTimeout(snapToNearestColumn, SCROLL_IDLE_DELAY_MS); + if (!interactionActive) return; + const current = scroller.scrollLeft; + if (current === lastScrollLeft) return; + lastScrollLeft = current; + markMoved(); + + // While finger is down: free-scroll only. After lift: re-arm idle (momentum). + if (pointerHeld) return; + armIdleSettle(); + }; + + const handleFingerLift = (event: Event) => { + if (!interactionActive || pinnedScrollLeft !== null) return; + if ("isPrimary" in event && (event as PointerEvent).isPrimary === false) return; + + pointerHeld = false; + releasePointerCapture(); + // FNXC:BoardNavigation 2026-07-22-18:00: Lock direction now from net gesture only. + lockDirectionFromGesture(); + + if (!sawHorizontalMovement && lockedDirection === 0) { + clearIdleTimer(); + snapInScrollDirection(); + return; + } + armIdleSettle(); + }; + + const handleGestureCancel = () => { + if (!interactionActive || pinnedScrollLeft !== null) return; + pointerHeld = false; + releasePointerCapture(); + lockDirectionFromGesture(); + if (sawHorizontalMovement || lockedDirection !== 0) { + armIdleSettle(); + } else { + interactionActive = false; + restoreNativeSnap(); + } }; const handleScrollEnd = () => { - if (isSnapping) { - isSnapping = false; - if (snapReleaseTimer !== null) clearTimeout(snapReleaseTimer); - snapReleaseTimer = null; - restoreNativeSnap(); + if (pinnedScrollLeft !== null) { + scroller.scrollLeft = pinnedScrollLeft; return; } - snapToNearestColumn(); + if (pointerHeld) return; + if (!interactionActive) return; + snapInScrollDirection(); }; scroller.addEventListener("pointerdown", beginInteraction); - scroller.addEventListener("touchstart", beginInteraction); + scroller.addEventListener("touchstart", beginInteraction, { passive: true }); scroller.addEventListener("wheel", beginInteraction, { passive: true }); + scroller.addEventListener("pointermove", handlePointerMove, { passive: true }); + scroller.addEventListener("touchmove", handlePointerMove, { passive: true }); scroller.addEventListener("scroll", handleScroll, { passive: true }); scroller.addEventListener("scrollend", handleScrollEnd); - scroller.addEventListener("pointerup", handleInteractionEnd); - scroller.addEventListener("touchend", handleInteractionEnd); + scroller.addEventListener("pointerup", handleFingerLift); + scroller.addEventListener("touchend", handleFingerLift); + scroller.addEventListener("pointercancel", handleGestureCancel); + scroller.addEventListener("touchcancel", handleGestureCancel); return () => { clearIdleTimer(); - if (snapReleaseTimer !== null) clearTimeout(snapReleaseTimer); + clearPin(); + releasePointerCapture(); restoreNativeSnap(); scroller.removeEventListener("pointerdown", beginInteraction); scroller.removeEventListener("touchstart", beginInteraction); scroller.removeEventListener("wheel", beginInteraction); + scroller.removeEventListener("pointermove", handlePointerMove); + scroller.removeEventListener("touchmove", handlePointerMove); scroller.removeEventListener("scroll", handleScroll); scroller.removeEventListener("scrollend", handleScrollEnd); - scroller.removeEventListener("pointerup", handleInteractionEnd); - scroller.removeEventListener("touchend", handleInteractionEnd); + scroller.removeEventListener("pointerup", handleFingerLift); + scroller.removeEventListener("touchend", handleFingerLift); + scroller.removeEventListener("pointercancel", handleGestureCancel); + scroller.removeEventListener("touchcancel", handleGestureCancel); }; }, [isEligibleViewport, isUserInteraction, scroller]); } diff --git a/packages/dashboard/app/styles.css b/packages/dashboard/app/styles.css index 439f0a707e..ecd7dbd147 100644 --- a/packages/dashboard/app/styles.css +++ b/packages/dashboard/app/styles.css @@ -3617,7 +3617,13 @@ Toast text must contrast its status background across every dashboard theme and scroll-snap-type: x proximity; overflow-anchor: none; scroll-padding-inline: calc(50% - 150px); - scroll-behavior: smooth; + /* + FNXC:BoardNavigation 2026-07-22-14:20: + Mobile board column paging uses a short JS ease-out snap. CSS scroll-behavior:smooth + stacked with browser smooth scroll made settle feel laggy; keep auto so free-pan + inertia and the hook's animation stay snappy. + */ + scroll-behavior: auto; scrollbar-width: none; padding: var(--space-md); padding-bottom: var(--space-md);