From e01dc7d3d3d56d61aa656ee343e2962dd760cb3f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 24 Jul 2026 22:16:11 -0700 Subject: [PATCH] feat(board): speed up kanban scrolling and drop desktop snapping Desktop board scrollers no longer snap: the browser's proximity-snap settle animation was capturing wheel/trackpad pans and reading as a slow, sticky drag toward a column center. Base `.board`, `.board-workflow-columns`, and `.lane-columns` declare `scroll-snap-type: none`; proximity snap is re-declared in phone-tier media blocks only, where the JS column pager owns paging. On phones, the hook now owns post-lift motion instead of waiting it out. A directional lift kills native inertia and animates to its target column via rAF ease-out (~190-300ms), so the page starts moving on lift rather than after a native fling that can coast for most of a second. Fling reach is preserved by deriving a page count (1-3) from release velocity sampled off the board's own scroll ticks, not from however far inertia happens to travel. Guards: re-touch cancels the animation and hands the axis back to the finger; reduced motion, missing rAF, and sub-2px distances fall back to the instant hard jump; unmount mid-animation restores the frozen inline styles; a fast drag that rests before lifting is not treated as a flick. Tap-to-stop-during-momentum is gone as an interaction (no long coast remains to interrupt). Its regression test is reframed around the equivalent seam: a drag that interrupts the page animation wins over the pending page. Co-Authored-By: Claude Opus 5 (1M context) --- .../faster-board-scroll-no-desktop-snap.md | 7 + .../board-mobile-column-swipe.test.ts | 20 +- ...oard-mobile-overscroll-containment.test.ts | 25 +- .../app/__tests__/mobile-scroll-snap.test.ts | 14 +- packages/dashboard/app/components/Lane.css | 23 +- .../__tests__/useColumnScrollSnap.test.ts | 275 +++++++++++++-- .../app/hooks/useColumnScrollSnap.ts | 317 +++++++++++++++++- packages/dashboard/app/styles.css | 10 +- 8 files changed, 641 insertions(+), 50 deletions(-) create mode 100644 .changeset/faster-board-scroll-no-desktop-snap.md diff --git a/.changeset/faster-board-scroll-no-desktop-snap.md b/.changeset/faster-board-scroll-no-desktop-snap.md new file mode 100644 index 0000000000..30a0d0196d --- /dev/null +++ b/.changeset/faster-board-scroll-no-desktop-snap.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Board scrolling feels faster — desktop no longer snaps, and phone swipes page immediately instead of coasting. +category: feature +dev: Base `.board`/`.board-workflow-columns`/`.lane-columns` declare `scroll-snap-type: none`; proximity snap is re-declared in phone-tier media blocks only. `useColumnScrollSnap` now owns post-lift motion: a directional lift kills native inertia (`overflow-x: hidden` for the animation) and animates to its target column via rAF ease-out (~190-300ms), with the page count derived from release velocity sampled off scroll ticks (`resolvePageCount`, `resolveFlingTargetIndex`, `resolvePageAnimationMs`). Re-touch cancels the animation; reduced motion and missing rAF fall back to the instant hard jump. Tap-to-stop-during-momentum is gone as an interaction (no long coast remains). diff --git a/packages/dashboard/app/__tests__/board-mobile-column-swipe.test.ts b/packages/dashboard/app/__tests__/board-mobile-column-swipe.test.ts index 4f8230d683..babdc72479 100644 --- a/packages/dashboard/app/__tests__/board-mobile-column-swipe.test.ts +++ b/packages/dashboard/app/__tests__/board-mobile-column-swipe.test.ts @@ -61,10 +61,16 @@ function expectTouchPanXY(css: string, selector: string): void { expect(block).not.toMatch(/touch-action:\s*pan-y\s*;/); } -function expectContainmentScroller(block: string): void { +/* +FNXC:BoardNavigation 2026-07-24-10:05: +Snap expectations split by tier: FN-6378 overscroll containment still holds on every board +scroller, but proximity snapping is phone-tier only (desktop pans free, with no browser snap +settle animation). `snap: "proximity"` asserts the mobile blocks, `snap: "none"` the base rules. +*/ +function expectContainmentScroller(block: string, snap: "proximity" | "none" = "proximity"): void { expect(block).toContain("overflow-x: auto"); expect(block).toContain("overscroll-behavior-x: contain"); - expect(block).toContain("scroll-snap-type: x proximity"); + expect(block).toContain(snap === "proximity" ? "scroll-snap-type: x proximity" : "scroll-snap-type: none"); expect(block).not.toContain("scroll-snap-type: x mandatory"); } @@ -114,10 +120,10 @@ describe("mobile board column swipe target containment (FN-6389)", () => { expect(widthContainmentBlock).toContain("max-inline-size: 100%;"); }); - it("preserves FN-6378 horizontal overscroll containment and proximity snap", () => { - expectContainmentScroller(ruleBlock(baseCss, ".board")); - expectContainmentScroller(ruleBlock(mobileCss, ".board")); - expectContainmentScroller(ruleBlock(baseCss, ".board.board-workflow-columns")); - expectContainmentScroller(ruleBlock(baseCss, ".lane-columns")); + it("preserves FN-6378 horizontal overscroll containment, with proximity snap on phones and none on desktop", () => { + expectContainmentScroller(ruleBlock(baseCss, ".board"), "none"); + expectContainmentScroller(ruleBlock(mobileCss, ".board"), "proximity"); + expectContainmentScroller(ruleBlock(baseCss, ".board.board-workflow-columns"), "none"); + expectContainmentScroller(ruleBlock(baseCss, ".lane-columns"), "none"); }); }); diff --git a/packages/dashboard/app/__tests__/board-mobile-overscroll-containment.test.ts b/packages/dashboard/app/__tests__/board-mobile-overscroll-containment.test.ts index 72dbb4cb5d..b39733df81 100644 --- a/packages/dashboard/app/__tests__/board-mobile-overscroll-containment.test.ts +++ b/packages/dashboard/app/__tests__/board-mobile-overscroll-containment.test.ts @@ -42,24 +42,37 @@ describe("board-mobile-overscroll-containment (FN-6378)", () => { expect(boardBlock).not.toContain("scroll-snap-type: x mandatory"); }); - it("base .board contains horizontal overscroll for shared and tablet board scrollers", () => { + /* + FNXC:BoardNavigation 2026-07-24-10:05: + Desktop must never snap: the base scrollers keep overscroll containment but declare + `scroll-snap-type: none`, so wheel/trackpad panning is free of the browser's snap settle + animation. Snapping is asserted only inside the phone-tier media blocks above/below. + */ + it("base .board contains horizontal overscroll and does not snap on desktop", () => { const boardBlock = extractRuleBlock(baseCss, ".board"); expect(boardBlock).toContain("overflow-x: auto"); expect(boardBlock).toContain("overscroll-behavior-x: contain"); - expect(boardBlock).toContain("scroll-snap-type: x proximity"); - expect(boardBlock).not.toContain("scroll-snap-type: x mandatory"); + expect(boardBlock).toContain("scroll-snap-type: none"); + expect(boardBlock).not.toContain("scroll-snap-type: x"); }); - it("workflow columns and multi-lane column strips contain horizontal overscroll", () => { + it("workflow columns and multi-lane column strips contain horizontal overscroll and do not snap on desktop", () => { const workflowColumnsBlock = extractRuleBlock(baseCss, ".board.board-workflow-columns"); const laneColumnsBlock = extractRuleBlock(baseCss, ".lane-columns"); for (const block of [workflowColumnsBlock, laneColumnsBlock]) { expect(block).toContain("overflow-x: auto"); expect(block).toContain("overscroll-behavior-x: contain"); - expect(block).toContain("scroll-snap-type: x proximity"); - expect(block).not.toContain("scroll-snap-type: x mandatory"); + expect(block).toContain("scroll-snap-type: none"); + expect(block).not.toContain("scroll-snap-type: x"); } }); + + it("phone tier re-enables proximity snapping for workflow columns and lane strips", () => { + // Both live in Lane.css's mobile block; assert against the mobile slice of all app CSS. + expect(mobileCss).toContain("scroll-snap-type: x proximity"); + expect(mobileCss).not.toContain("scroll-snap-type: x mandatory"); + expect(mobileCss).toMatch(/\.board\.board-workflow-columns,\s*\n?\s*\.lane-columns\s*\{[^}]*scroll-snap-type: x proximity/); + }); }); diff --git a/packages/dashboard/app/__tests__/mobile-scroll-snap.test.ts b/packages/dashboard/app/__tests__/mobile-scroll-snap.test.ts index 88e678263b..08212b7ad9 100644 --- a/packages/dashboard/app/__tests__/mobile-scroll-snap.test.ts +++ b/packages/dashboard/app/__tests__/mobile-scroll-snap.test.ts @@ -1,14 +1,20 @@ import { describe, it, expect } from "vitest"; import { loadAllAppCss } from "../test/cssFixture"; -import { readFileSync } from "fs"; -import { resolve } from "path"; const css = loadAllAppCss(); describe("scroll-snap CSS", () => { describe("base (desktop) styles", () => { - it("contains scroll-snap-type: x proximity on .board", () => { - expect(css).toContain("scroll-snap-type: x proximity"); + /* + FNXC:BoardNavigation 2026-07-24-10:05: + Desktop board scrolling is free-panning — the base `.board` rule must declare + `scroll-snap-type: none` so no browser snap settle animation slows a wheel/trackpad pan. + */ + it("turns scroll snapping off on .board (desktop free-pan)", () => { + const boardBlock = css.match(/\.board\s*\{[^}]*\}/)?.[0]; + expect(boardBlock).toBeDefined(); + expect(boardBlock).toContain("scroll-snap-type: none"); + expect(boardBlock).not.toContain("scroll-snap-type: x"); }); it("contains scroll-snap-align: center on .column", () => { diff --git a/packages/dashboard/app/components/Lane.css b/packages/dashboard/app/components/Lane.css index b7633e3c2c..5c67f94a21 100644 --- a/packages/dashboard/app/components/Lane.css +++ b/packages/dashboard/app/components/Lane.css @@ -57,7 +57,12 @@ overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; - scroll-snap-type: x proximity; + /* + FNXC:BoardNavigation 2026-07-24-10:05: + Desktop/tablet workflow-column scrolling is free-panning — snapping (and its slow browser + settle animation) is reserved for the phone tier, where the JS column pager owns paging. + */ + scroll-snap-type: none; touch-action: pan-x pan-y; } @@ -125,7 +130,8 @@ overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; - scroll-snap-type: x proximity; + /* FNXC:BoardNavigation 2026-07-24-10:05: lane columns snap on phones only (see mobile block). */ + scroll-snap-type: none; scrollbar-color: var(--border) transparent; scrollbar-width: thin; min-height: 0; @@ -184,6 +190,19 @@ min-height: 0; overflow-x: auto; overflow-y: hidden; + scroll-snap-type: none; + } +} + +/* +FNXC:BoardNavigation 2026-07-24-10:05: +Phone tier keeps proximity snapping (paired with the JS column pager in useColumnScrollSnap) so a +swipe still rests on exactly one column center. Mirrors the mobile `.board` block in styles.css and +matches the mobile viewport query (max-width: 768px, max-height: 480px). +*/ +@media (max-width: 768px), (max-height: 480px) { + .board.board-workflow-columns, + .lane-columns { scroll-snap-type: x proximity; } } diff --git a/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts b/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts index 420b473050..f0b4fb5956 100644 --- a/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts @@ -2,6 +2,9 @@ import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { isColumnCentered, + resolveFlingTargetIndex, + resolvePageAnimationMs, + resolvePageCount, resolvePanDirection, resolveSettleTargetIndex, useColumnScrollSnap, @@ -95,9 +98,17 @@ function dispatchShortSwipe( dispatchPointerEvent(scroller, "pointerup", 200 - clientDelta); } +/* +FNXC:BoardNavigation 2026-07-24-11:20: +The hook now owns the post-lift motion: a directional lift animates to its target column instead of +waiting out native inertia. Settling therefore means "run the page animation to completion", so this +helper advances past both the idle fallback and the longest page animation. +*/ +const SETTLE_ADVANCE_MS = 400; + function settleAfterMomentum(): void { act(() => { - vi.advanceTimersByTime(48); + vi.advanceTimersByTime(SETTLE_ADVANCE_MS); }); } @@ -117,6 +128,73 @@ describe("resolvePanDirection", () => { }); }); +/* +FNXC:BoardNavigation 2026-07-24-11:20: +Owning the momentum means reach can no longer come from however far native inertia coasts, so it +comes from release velocity instead. These guard that mapping: deliberate swipe = one column, hard +flick = more, with a hard ceiling. +*/ +describe("resolvePageCount", () => { + it("pages exactly one column for a deliberate slow swipe", () => { + expect(resolvePageCount(0)).toBe(1); + expect(resolvePageCount(0.4)).toBe(1); + expect(resolvePageCount(1.5)).toBe(1); + }); + + it("buys extra columns as release velocity climbs", () => { + expect(resolvePageCount(1.7)).toBe(2); + expect(resolvePageCount(3.4)).toBe(3); + }); + + it("caps a hard flick so it cannot fly across the board", () => { + expect(resolvePageCount(40)).toBe(3); + }); + + it("is direction-agnostic (magnitude only) and ignores non-finite input", () => { + expect(resolvePageCount(-3.4)).toBe(3); + expect(resolvePageCount(Number.NaN)).toBe(1); + }); +}); + +describe("resolvePageAnimationMs", () => { + it("keeps a single-column hop short and grows sublinearly, capped", () => { + const single = resolvePageAnimationMs(1); + const triple = resolvePageAnimationMs(3); + expect(single).toBeGreaterThan(0); + expect(single).toBeLessThanOrEqual(220); + expect(triple).toBeGreaterThan(single); + expect(triple).toBeLessThanOrEqual(300); + // Absurd counts clamp at the ceiling rather than growing without bound. + expect(resolvePageAnimationMs(50)).toBe(300); + }); +}); + +describe("resolveFlingTargetIndex", () => { + const base = { columnCount: 5, nearestIndex: 0 }; + + it("advances pageCount columns from the origin in the locked direction", () => { + expect(resolveFlingTargetIndex({ ...base, originIndex: 0, direction: 1, pageCount: 1 })).toBe(1); + expect(resolveFlingTargetIndex({ ...base, originIndex: 0, direction: 1, pageCount: 3 })).toBe(3); + expect(resolveFlingTargetIndex({ ...base, originIndex: 4, direction: -1, pageCount: 2, nearestIndex: 4 })).toBe(2); + }); + + it("clamps to the column range at both edges", () => { + expect(resolveFlingTargetIndex({ ...base, originIndex: 4, direction: 1, pageCount: 3, nearestIndex: 4 })).toBe(4); + expect(resolveFlingTargetIndex({ ...base, originIndex: 0, direction: -1, pageCount: 3 })).toBe(0); + }); + + it("never animates backwards past a column the finger already dragged onto", () => { + // Long slow drag landed on column 2 while the origin was 0: keep the drag's landing. + expect(resolveFlingTargetIndex({ ...base, originIndex: 0, direction: 1, pageCount: 1, nearestIndex: 2 })).toBe(2); + expect(resolveFlingTargetIndex({ ...base, originIndex: 4, direction: -1, pageCount: 1, nearestIndex: 2 })).toBe(2); + }); + + it("falls back to the nearest column with no direction, and to 0 below two columns", () => { + expect(resolveFlingTargetIndex({ ...base, originIndex: 0, direction: 0, pageCount: 2, nearestIndex: 3 })).toBe(3); + expect(resolveFlingTargetIndex({ columnCount: 1, originIndex: 0, direction: 1, pageCount: 2, nearestIndex: 0 })).toBe(0); + }); +}); + describe("isColumnCentered", () => { it("recognizes only an integer column-centering target", () => { const scroller = createScroller(3, COLUMN_WIDTH); @@ -261,7 +339,13 @@ describe("useColumnScrollSnap", () => { expect(scroller.scrollLeft).toBe(0); }); - it("free-scrolls while dragging and coasts after lift before snapping", () => { + /* + FNXC:BoardNavigation 2026-07-24-11:20: + Free-scroll while the finger is DOWN is still untouched. What changed is after lift: the hook + animates to the target column itself, so a residual native-inertia write mid-animation cannot + redirect the destination. + */ + it("free-scrolls while dragging, then owns the motion after lift", () => { const scroller = createScroller(); renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); @@ -270,15 +354,39 @@ describe("useColumnScrollSnap", () => { scroller.scrollLeft = 40; scroller.dispatchEvent(new Event("scroll")); dispatchPointerEvent(scroller, "pointermove", 160); - dispatchPointerEvent(scroller, "pointerup", 160); }); + // Finger still down: the board rests wherever it was dragged. expect(scroller.scrollLeft).toBe(40); act(() => { + dispatchPointerEvent(scroller, "pointerup", 160); + // Residual compositor inertia tick arriving after lift, mid page animation. scroller.scrollLeft = 70; scroller.dispatchEvent(new Event("scroll")); }); - expect(scroller.scrollLeft).toBe(70); + + settleAfterMomentum(); + expect(scroller.scrollLeft).toBe(COLUMN_WIDTH); + }); + + /* + FNXC:BoardNavigation 2026-07-24-11:20: + Owning the momentum means the page starts moving on lift rather than after the native coast. + Guard the observable part of that: partway through the animation the board has already left the + release point and is heading toward the target column. + */ + it("starts moving toward the target column during the page animation", () => { + const scroller = createScroller(3, 0); + renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => dispatchShortSwipe(scroller, { scrollDelta: 8, clientDelta: 20 })); + + act(() => { + vi.advanceTimersByTime(64); + }); + const midFlight = scroller.scrollLeft; + expect(midFlight).toBeGreaterThan(8); + expect(midFlight).toBeLessThan(COLUMN_WIDTH); settleAfterMomentum(); expect(scroller.scrollLeft).toBe(COLUMN_WIDTH); @@ -308,36 +416,43 @@ describe("useColumnScrollSnap", () => { }); /* - FNXC:BoardNavigation 2026-07-22-21:40: - Tap-to-stop during momentum, then drag: the new drag's landing point must win. The - commit-one-column clamp only applies to gestures that began centered at rest — from a - mid-transit origin it forced a page past the corrective drag. + FNXC:BoardNavigation 2026-07-22-21:40 (reframed 2026-07-24-11:20): + The corrective seam used to be tap-to-stop during native momentum; owning the momentum replaces + that long coast with a ~200ms animation, so the equivalent guard is a re-touch DURING the page + animation. It must cancel the pending page and let the new drag's direction win. */ - it("takes the new drag's landing point after a tap-to-stop mid-transit", () => { + it("lets a drag that interrupts the page animation win over the pending page", () => { const scroller = createScroller(3, 0); renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); act(() => { - // Swipe right, coast mid-transit past column 1's center. + // Swipe right: the hook starts animating toward column 1. dispatchPointerEvent(scroller, "pointerdown", 200); dispatchPointerEvent(scroller, "pointermove", 160); scroller.scrollLeft = 30; scroller.dispatchEvent(new Event("scroll")); dispatchPointerEvent(scroller, "pointerup", 160); - scroller.scrollLeft = 130; - scroller.dispatchEvent(new Event("scroll")); + }); - // Tap to stop, then drag back left onto column 1's center. + // Let the page animation get most of the way to column 1, then grab it. + act(() => { + vi.advanceTimersByTime(150); + }); + expect(scroller.scrollLeft).toBeGreaterThan(30); + expect(scroller.scrollLeft).toBeLessThan(COLUMN_WIDTH); + + act(() => { + // Re-touch cancels the animation; drag back left. dispatchPointerEvent(scroller, "pointerdown", 150); - dispatchPointerEvent(scroller, "pointermove", 180); - scroller.scrollLeft = 100; + dispatchPointerEvent(scroller, "pointermove", 190); + scroller.scrollLeft = 60; scroller.dispatchEvent(new Event("scroll")); - dispatchPointerEvent(scroller, "pointerup", 180); + dispatchPointerEvent(scroller, "pointerup", 190); }); settleAfterMomentum(); - // Regression: the min-progress clamp previously forced column 0 (scrollLeft 0). - expect(scroller.scrollLeft).toBe(COLUMN_WIDTH); + // The leftward corrective drag wins: back to column 0, never onward to column 1. + expect(scroller.scrollLeft).toBe(0); expect(isColumnCentered(scroller, [...scroller.children] as HTMLElement[])).toBe(true); }); @@ -384,12 +499,14 @@ describe("useColumnScrollSnap", () => { act(() => { dispatchShortSwipe(scroller, { scrollDelta: 10, clientDelta: 20 }); - // iOS can report scrollend before its final compositor fling tick. + // iOS can report scrollend before its final compositor fling tick — and, now, before the + // hook-owned page animation has finished. It must not abort the page. scroller.dispatchEvent(new Event("scrollend")); - expect(scroller.scrollLeft).toBe(COLUMN_WIDTH); - // Let multiple watchdog passes complete before the callback-less compositor write. - vi.advanceTimersByTime(48); + // Run the page animation out, then let watchdog passes complete before the + // callback-less compositor write. + vi.advanceTimersByTime(SETTLE_ADVANCE_MS); + expect(scroller.scrollLeft).toBe(COLUMN_WIDTH); scroller.scrollLeft = COLUMN_WIDTH + 40; vi.advanceTimersByTime(16); }); @@ -399,7 +516,12 @@ describe("useColumnScrollSnap", () => { expect(isColumnCentered(scroller, columns)).toBe(true); }); - it("does not snap on touchcancel mid-drag", () => { + /* + FNXC:BoardNavigation 2026-07-24-11:20: + `touchcancel` is a genuine gesture end (unlike a pointercancel with a live touch stream), so a + cancelled pan pages on the same owned animation as a lift instead of coasting to an idle settle. + */ + it("pages a cancelled pan gesture on touchcancel", () => { const scroller = createScroller(); renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); @@ -408,11 +530,13 @@ describe("useColumnScrollSnap", () => { dispatchPointerEvent(scroller, "pointermove", 170); scroller.scrollLeft = 25; scroller.dispatchEvent(new Event("scroll")); - scroller.dispatchEvent(new Event("touchcancel")); - vi.advanceTimersByTime(30); }); + // Finger still down: no snapping mid-drag. expect(scroller.scrollLeft).toBe(25); + act(() => { + scroller.dispatchEvent(new Event("touchcancel")); + }); settleAfterMomentum(); expect(scroller.scrollLeft).toBe(COLUMN_WIDTH); }); @@ -620,6 +744,107 @@ describe("useColumnScrollSnap", () => { expect(isColumnCentered(scroller, [...scroller.children] as HTMLElement[])).toBe(true); }); + /* + FNXC:BoardNavigation 2026-07-24-11:20: + Killing native inertia must not cost fling REACH. A hard flick still crosses several columns + because the page count comes from release velocity sampled off the board's own scroll ticks. + */ + it("crosses multiple columns for a hard flick and one column for a slow swipe", () => { + const fastScroller = createScroller(5, 0); + renderHook(() => useColumnScrollSnap(fastScroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => { + fastScroller.dispatchEvent(new Event("touchstart")); + dispatchPointerEvent(fastScroller, "pointerdown", 300); + // ~4 px/ms of real content travel while the finger is down. + for (let tick = 1; tick <= 3; tick++) { + vi.advanceTimersByTime(10); + dispatchPointerEvent(fastScroller, "pointermove", 300 - tick * 40); + fastScroller.scrollLeft = tick * 40; + fastScroller.dispatchEvent(new Event("scroll")); + } + dispatchPointerEvent(fastScroller, "pointerup", 180); + }); + settleAfterMomentum(); + + expect(fastScroller.scrollLeft).toBe(COLUMN_WIDTH * 3); + + const slowScroller = createScroller(5, 0); + renderHook(() => useColumnScrollSnap(slowScroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => { + slowScroller.dispatchEvent(new Event("touchstart")); + dispatchPointerEvent(slowScroller, "pointerdown", 300); + // ~0.3 px/ms: a deliberate drag, not a flick. + for (let tick = 1; tick <= 3; tick++) { + vi.advanceTimersByTime(50); + dispatchPointerEvent(slowScroller, "pointermove", 300 - tick * 15); + slowScroller.scrollLeft = tick * 15; + slowScroller.dispatchEvent(new Event("scroll")); + } + dispatchPointerEvent(slowScroller, "pointerup", 255); + }); + settleAfterMomentum(); + + expect(slowScroller.scrollLeft).toBe(COLUMN_WIDTH); + }); + + /* + FNXC:BoardNavigation 2026-07-24-11:20: + A finger that moved fast and then HELD STILL before lifting is not a flick — stale velocity must + not page it three columns. + */ + it("does not treat a fast drag that rests before lift as a flick", () => { + const scroller = createScroller(5, 0); + renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => { + scroller.dispatchEvent(new Event("touchstart")); + dispatchPointerEvent(scroller, "pointerdown", 300); + for (let tick = 1; tick <= 3; tick++) { + vi.advanceTimersByTime(10); + dispatchPointerEvent(scroller, "pointermove", 300 - tick * 40); + scroller.scrollLeft = tick * 40; + scroller.dispatchEvent(new Event("scroll")); + } + // Finger parks for a beat with no further scroll ticks, then lifts. + vi.advanceTimersByTime(300); + dispatchPointerEvent(scroller, "pointerup", 180); + }); + settleAfterMomentum(); + + // Nearest column at release (120 -> column 1) rather than a 3-column flick. + expect(scroller.scrollLeft).toBe(COLUMN_WIDTH); + }); + + /* + FNXC:BoardNavigation 2026-07-24-11:20: + Reduced-motion users get the destination without the animation — the page still lands on a column + center, it just arrives instantly. + */ + it("jumps instead of animating when the user prefers reduced motion", () => { + vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ + matches: + query === "(max-width: 768px)" || + query === "(max-height: 480px)" || + query === "(prefers-reduced-motion: reduce)", + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(() => true), + }))); + const scroller = createScroller(3, 0); + renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => dispatchShortSwipe(scroller, { scrollDelta: 8, clientDelta: 20 })); + + // No timer advance: the target is already applied at lift. + expect(scroller.scrollLeft).toBe(COLUMN_WIDTH); + }); + it("does not attach on non-phone desktop", () => { stubViewport("wide-short-desktop"); expect(isMobileViewport()).toBe(false); diff --git a/packages/dashboard/app/hooks/useColumnScrollSnap.ts b/packages/dashboard/app/hooks/useColumnScrollSnap.ts index 5352e38d11..7c7fdc949e 100644 --- a/packages/dashboard/app/hooks/useColumnScrollSnap.ts +++ b/packages/dashboard/app/hooks/useColumnScrollSnap.ts @@ -20,14 +20,120 @@ between columns. Tap-to-stop and zero-pan lifts hard-jump to the nearest center cancelled swipe's directional page). Directional paging still applies only when the settle gesture itself had pan intent. */ -/** After lift/cancel/wheel: wait for scroll idle (momentum finished) before paging. */ -const SCROLL_IDLE_SETTLE_MS = 48; +/* +FNXC:BoardNavigation 2026-07-24-10:05: +Board paging must feel fast: the post-momentum quiet window is 2 frames (~32ms), not 3 (~48ms), so +the settle commits sooner after a swipe. Keep it above one frame — a single-frame window can fire +mid-fling and page against travel. +*/ +/** + * Fallback quiet window for settles that cannot page at lift (wheel, net-zero direction). + * Directional finger swipes no longer wait on it — see `commitDirectionalPage`. + */ +const SCROLL_IDLE_SETTLE_MS = 32; const CENTER_TOLERANCE_PX = 1; /** Minimum finger travel to count as a horizontal pan (short swipe still commits). */ const MIN_PAN_CLIENT_PX = 12; /** Keep a WebKit compositor write from outliving the main-thread hard jump. */ const PIN_REASSERT_INTERVAL_MS = 16; +/* +FNXC:BoardNavigation 2026-07-24-11:20: +Board paging must feel fast, and the slow part was never the settle timer — it was waiting for the +BROWSER's fling to decelerate before paging (native inertia can coast for most of a second, so a +flick sat visibly drifting before it committed). The hook now owns the momentum: at finger-up a +directional swipe kills native inertia and animates to its target column in ~200ms, so the page +starts moving on lift instead of after the coast. Fling reach is preserved by deriving a page COUNT +from release velocity rather than from how far inertia happens to travel. + +Trade-off accepted: tap-to-stop-during-momentum no longer exists as an interaction (there is no +long coast left to interrupt). A re-touch during the page animation cancels it and hands control +back to the finger, which covers the same corrective intent. +*/ +/** Base duration of the owned page animation (single-column hop). */ +const PAGE_ANIMATION_BASE_MS = 190; +/** Added per extra column so multi-column flings do not crawl. */ +const PAGE_ANIMATION_PER_EXTRA_PAGE_MS = 45; +const PAGE_ANIMATION_MAX_MS = 300; +/** Only release-adjacent scroll samples describe fling speed. */ +const VELOCITY_SAMPLE_WINDOW_MS = 120; +/** px/ms of release velocity that buys one extra column of paging. */ +const FLING_VELOCITY_PER_EXTRA_PAGE = 1.6; +/** Ceiling so a hard flick cannot fly across the whole board. */ +const MAX_PAGES_PER_SWIPE = 3; +/** Below this the animation is pointless — jump. */ +const MIN_ANIMATED_DISTANCE_PX = 2; + +function now(): number { + if (typeof performance !== "undefined" && typeof performance.now === "function") { + return performance.now(); + } + return Date.now(); +} + +function prefersReducedMotion(): boolean { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false; + try { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches === true; + } catch { + return false; + } +} + +/** Ease-out cubic: fast departure, soft arrival — reads as "snappy", not "floaty". */ +function easeOutCubic(progress: number): number { + const clamped = progress <= 0 ? 0 : progress >= 1 ? 1 : progress; + return 1 - (1 - clamped) ** 3; +} + +/** + * Columns to advance for a release velocity, in px/ms (absolute value). + * + * A deliberate slow swipe pages exactly one column; faster releases buy extra columns so the + * hook's owned animation keeps the reach a native fling used to provide. + */ +export function resolvePageCount(velocityPxPerMs: number): number { + const speed = Math.abs(velocityPxPerMs); + if (!Number.isFinite(speed) || speed <= 0) return 1; + const extra = Math.floor(speed / FLING_VELOCITY_PER_EXTRA_PAGE); + return Math.min(1 + extra, MAX_PAGES_PER_SWIPE); +} + +/** Duration for a `pageCount`-column hop. */ +export function resolvePageAnimationMs(pageCount: number): number { + const extraPages = Math.max(0, pageCount - 1); + return Math.min( + PAGE_ANIMATION_BASE_MS + extraPages * PAGE_ANIMATION_PER_EXTRA_PAGE_MS, + PAGE_ANIMATION_MAX_MS, + ); +} + +/** + * Target column for an owned directional page. + * + * `originIndex` + `direction * pageCount`, clamped to the column range, then clamped forward to + * `floorIndex` (the column the finger already dragged onto) so a long slow drag never animates + * backwards to a stale origin-derived target. + */ +export function resolveFlingTargetIndex(options: { + columnCount: number; + originIndex: number; + direction: number; + pageCount: number; + /** Nearest column at release; keeps a long drag's own landing point. */ + nearestIndex: number; +}): number { + const { columnCount, originIndex, direction, pageCount, nearestIndex } = options; + if (columnCount <= 1) return 0; + const lastIndex = columnCount - 1; + const clamp = (value: number) => Math.min(Math.max(value, 0), lastIndex); + const origin = clamp(originIndex); + const nearest = clamp(nearestIndex); + if (direction === 0) return nearest; + const paged = clamp(origin + direction * Math.max(1, pageCount)); + return direction > 0 ? Math.max(paged, nearest) : Math.min(paged, nearest); +} + export interface UseColumnScrollSnapOptions { /** Restrict magnetic snapping to phone-class viewports. */ mobileOnly?: boolean; @@ -282,6 +388,19 @@ export function useColumnScrollSnap( let pinnedScrollLeft: number | null = null; /** Continues correcting late WebKit compositor writes until the next user interaction. */ let pinReassertTimer: ReturnType | null = null; + /* + FNXC:BoardNavigation 2026-07-24-11:20: + Release velocity comes from board scrollLeft samples taken while the finger is down, not from + finger coordinates: on iOS the native pan owns the touch stream, so scroll ticks are the only + faithful record of how fast the content was actually moving at lift. + */ + let velocitySampleScrollLeft = scroller.scrollLeft; + let velocitySampleAt = now(); + let releaseVelocityPxPerMs = 0; + /** rAF handle for the hook-owned page animation. */ + let pageAnimationFrame: number | null = null; + /** Inline styles frozen for the duration of the page animation. */ + let animationStyleRestore: (() => void) | null = null; const clearIdleTimer = () => { if (idleTimer !== null) clearTimeout(idleTimer); @@ -369,6 +488,95 @@ export function useColumnScrollSnap( reassertPinnedScrollLeft(); }; + /** + * Stop the hook-owned page animation and give the axis back to the browser. + * + * FNXC:BoardNavigation 2026-07-24-11:20: + * A re-touch during the animation must hand control straight back to the finger — this is the + * corrective seam that replaces tap-to-stop-during-momentum. + */ + const cancelPageAnimation = () => { + if (pageAnimationFrame !== null && typeof window.cancelAnimationFrame === "function") { + window.cancelAnimationFrame(pageAnimationFrame); + } + pageAnimationFrame = null; + if (animationStyleRestore) { + const restore = animationStyleRestore; + animationStyleRestore = null; + restore(); + } + }; + + /* + FNXC:BoardNavigation 2026-07-24-11:20: + `overflow-x: hidden` stays on for the WHOLE animation, not just the first frame: it is what + makes the compositor drop the native fling, and a fling left alive fights every per-frame + scrollLeft write (the board visibly stutters and can land off-center). Programmatic scrollLeft + still applies while the axis is hidden, so the animation itself is unaffected. + */ + const freezeScrollerForAnimation = () => { + if (animationStyleRestore) return; + const priorOverflowX = scroller.style.overflowX; + const priorBehavior = scroller.style.scrollBehavior; + const priorWebkit = scroller.style.getPropertyValue("-webkit-overflow-scrolling"); + scroller.style.scrollBehavior = "auto"; + scroller.style.overflowX = "hidden"; + scroller.style.setProperty("-webkit-overflow-scrolling", "auto"); + animationStyleRestore = () => { + scroller.style.overflowX = priorOverflowX; + scroller.style.scrollBehavior = priorBehavior; + if (priorWebkit) { + scroller.style.setProperty("-webkit-overflow-scrolling", priorWebkit); + } else { + scroller.style.removeProperty("-webkit-overflow-scrolling"); + } + }; + }; + + /** + * 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 + * unavailable, or the distance is not worth animating. + */ + const animateSnapTo = (targetLeft: number, durationMs: number) => { + const target = Math.round(targetLeft); + const from = scroller.scrollLeft; + const distance = target - from; + + pointerHeld = false; + suspendNativeSnap(); + cancelPageAnimation(); + + if ( + Math.abs(distance) < MIN_ANIMATED_DISTANCE_PX || + durationMs <= 0 || + prefersReducedMotion() || + typeof window.requestAnimationFrame !== "function" + ) { + applySnapTo(target); + return; + } + + freezeScrollerForAnimation(); + const startedAt = now(); + + const step = () => { + pageAnimationFrame = null; + const elapsed = now() - startedAt; + const progress = elapsed / durationMs; + if (progress >= 1) { + cancelPageAnimation(); + applySnapTo(target); + return; + } + scroller.scrollLeft = Math.round(from + distance * easeOutCubic(progress)); + pageAnimationFrame = window.requestAnimationFrame(step); + }; + + pageAnimationFrame = window.requestAnimationFrame(step); + }; + /** * FNXC:BoardNavigation 2026-07-22-15:26: * Hard-jump to the nearest column center when off-center. Returns true when a snap @@ -483,6 +691,79 @@ export function useColumnScrollSnap( idleTimer = setTimeout(snapInScrollDirection, SCROLL_IDLE_SETTLE_MS); }; + /** + * Page immediately at finger-up, animating the board there ourselves. + * + * FNXC:BoardNavigation 2026-07-24-11:20: + * This is the "faster momentum" path: instead of arming the idle settle and waiting out native + * inertia, a directional lift resolves its target from the ORIGIN column plus a velocity-derived + * page count and animates there in ~200ms. Reach scales with flick speed, so a hard fling still + * crosses multiple columns without the long coast. + */ + const commitDirectionalPage = (direction: number) => { + clearIdleTimer(); + + const columns = getSnapColumns(scroller); + const viewportWidth = scroller.clientWidth || scroller.getBoundingClientRect().width; + if (columns.length < 2 || viewportWidth <= 0) { + interactionActive = false; + restoreNativeSnap(); + return; + } + + const nearestIndex = nearestColumnIndex(scroller, columns); + // A gesture begun mid-transit has no trustworthy origin: page from where it actually is. + const originIndex = gestureStartCentered ? gestureStartColumnIndex : nearestIndex; + const pageCount = resolvePageCount(resolveReleaseVelocity()); + const targetIndex = resolveFlingTargetIndex({ + columnCount: columns.length, + originIndex, + direction, + pageCount, + nearestIndex, + }); + + interactionActive = false; + sawHorizontalMovement = false; + lockedDirection = 0; + gestureStartClientX = null; + lastClientX = null; + gestureStartClientY = null; + lastClientY = null; + releaseVelocityPxPerMs = 0; + + animateSnapTo( + scrollLeftToCenterColumn(scroller, columns[targetIndex]), + resolvePageAnimationMs(pageCount), + ); + }; + + /** Reset the release-velocity window at the start of every fresh gesture baseline. */ + const resetVelocitySampling = () => { + velocitySampleScrollLeft = scroller.scrollLeft; + velocitySampleAt = now(); + releaseVelocityPxPerMs = 0; + }; + + /** Fold one scroll tick into the release-velocity estimate (px/ms, signed). */ + const sampleVelocity = (currentScrollLeft: number) => { + const at = now(); + const elapsed = at - velocitySampleAt; + // Synchronous same-instant ticks (and test batches) carry no speed information. + if (elapsed <= 0) return; + releaseVelocityPxPerMs = (currentScrollLeft - velocitySampleScrollLeft) / elapsed; + velocitySampleScrollLeft = currentScrollLeft; + velocitySampleAt = at; + }; + + /* + FNXC:BoardNavigation 2026-07-24-11:20: + A finger that moved fast and then held still before lifting must NOT page like a flick: the last + sample would still read fast. Velocity older than the sample window counts as a resting finger. + */ + const resolveReleaseVelocity = (): number => + now() - velocitySampleAt > VELOCITY_SAMPLE_WINDOW_MS ? 0 : releaseVelocityPxPerMs; + /* FNXC:BoardNavigation 2026-07-22-15:10: A second touch during post-lift momentum must cancel the pending directional settle and start a fresh gesture at the current scrollLeft. @@ -493,6 +774,13 @@ export function useColumnScrollSnap( if (event.type === "touchstart") touchSequenceActive = true; clearPin(); + /* + FNXC:BoardNavigation 2026-07-24-11:20: + A touch landing mid-animation takes the axis back immediately (overflow restored, rAF + dropped) so the finger drags from wherever the page had reached. + */ + cancelPageAnimation(); + resetVelocitySampling(); // Mid-momentum re-touch (or duplicate pointerdown+touchstart): cancel pending snap and re-baseline. if (interactionActive) { @@ -593,8 +881,12 @@ export function useColumnScrollSnap( lastScrollLeft = current; markMoved(); - // While finger is down: free-scroll only. After lift: re-arm idle (momentum). - if (pointerHeld) return; + // While finger is down: free-scroll only, sampling speed for the release page count. + if (pointerHeld) { + sampleVelocity(current); + return; + } + // Post-lift ticks (residual inertia before our page takes over): keep the fallback armed. armIdleSettle(); }; @@ -614,6 +906,16 @@ export function useColumnScrollSnap( snapInScrollDirection(); return; } + /* + FNXC:BoardNavigation 2026-07-24-11:20: + Directional lift pages NOW instead of arming the idle settle — the whole point of owning the + momentum. Net-zero-direction pans (weak or reversed gestures) still fall through to the idle + settle, which rests them on the nearest center. + */ + if (lockedDirection !== 0) { + commitDirectionalPage(lockedDirection); + return; + } armIdleSettle(); }; @@ -634,7 +936,10 @@ export function useColumnScrollSnap( pointerHeld = false; releasePointerCapture(); lockDirectionFromGesture(); - if (sawHorizontalMovement || lockedDirection !== 0) { + if (lockedDirection !== 0) { + // Genuine cancel with pan intent: page like a lift rather than coasting to an idle settle. + commitDirectionalPage(lockedDirection); + } else if (sawHorizontalMovement) { armIdleSettle(); } else { // FNXC:BoardNavigation 2026-07-22-15:26: Cancelled zero-pan touch must not leave mid-column. @@ -668,6 +973,8 @@ export function useColumnScrollSnap( return () => { clearIdleTimer(); clearPin(); + // Unmount mid-animation must not leave the scroller frozen at `overflow-x: hidden`. + cancelPageAnimation(); releasePointerCapture(); restoreNativeSnap(); scroller.removeEventListener("pointerdown", beginInteraction); diff --git a/packages/dashboard/app/styles.css b/packages/dashboard/app/styles.css index 846cb6ae06..b6dd1d90c5 100644 --- a/packages/dashboard/app/styles.css +++ b/packages/dashboard/app/styles.css @@ -1130,6 +1130,14 @@ body { } /* === Board === */ +/* +FNXC:BoardNavigation 2026-07-24-10:05: +Desktop board scrolling must be free-panning: no scroll snapping at all. Trackpad/wheel scrolls +were being captured by the browser's proximity-snap settle animation, which reads as a slow, +sticky drag toward a column center. Snapping stays a phone-tier affordance only (re-declared in +the mobile media block alongside the JS column pager), so the base rule turns it off explicitly +rather than relying on the absence of a declaration. +*/ .board { box-sizing: border-box; display: grid; @@ -1143,7 +1151,7 @@ body { overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; - scroll-snap-type: x proximity; + scroll-snap-type: none; scroll-padding-inline: 50%; scrollbar-color: var(--border) transparent; scrollbar-width: thin;