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) <noreply@anthropic.com>
This commit is contained in:
7
.changeset/faster-board-scroll-no-desktop-snap.md
Normal file
7
.changeset/faster-board-scroll-no-desktop-snap.md
Normal file
@@ -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).
|
||||||
@@ -61,10 +61,16 @@ function expectTouchPanXY(css: string, selector: string): void {
|
|||||||
expect(block).not.toMatch(/touch-action:\s*pan-y\s*;/);
|
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("overflow-x: auto");
|
||||||
expect(block).toContain("overscroll-behavior-x: contain");
|
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");
|
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%;");
|
expect(widthContainmentBlock).toContain("max-inline-size: 100%;");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves FN-6378 horizontal overscroll containment and proximity snap", () => {
|
it("preserves FN-6378 horizontal overscroll containment, with proximity snap on phones and none on desktop", () => {
|
||||||
expectContainmentScroller(ruleBlock(baseCss, ".board"));
|
expectContainmentScroller(ruleBlock(baseCss, ".board"), "none");
|
||||||
expectContainmentScroller(ruleBlock(mobileCss, ".board"));
|
expectContainmentScroller(ruleBlock(mobileCss, ".board"), "proximity");
|
||||||
expectContainmentScroller(ruleBlock(baseCss, ".board.board-workflow-columns"));
|
expectContainmentScroller(ruleBlock(baseCss, ".board.board-workflow-columns"), "none");
|
||||||
expectContainmentScroller(ruleBlock(baseCss, ".lane-columns"));
|
expectContainmentScroller(ruleBlock(baseCss, ".lane-columns"), "none");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,24 +42,37 @@ describe("board-mobile-overscroll-containment (FN-6378)", () => {
|
|||||||
expect(boardBlock).not.toContain("scroll-snap-type: x mandatory");
|
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");
|
const boardBlock = extractRuleBlock(baseCss, ".board");
|
||||||
|
|
||||||
expect(boardBlock).toContain("overflow-x: auto");
|
expect(boardBlock).toContain("overflow-x: auto");
|
||||||
expect(boardBlock).toContain("overscroll-behavior-x: contain");
|
expect(boardBlock).toContain("overscroll-behavior-x: contain");
|
||||||
expect(boardBlock).toContain("scroll-snap-type: x proximity");
|
expect(boardBlock).toContain("scroll-snap-type: none");
|
||||||
expect(boardBlock).not.toContain("scroll-snap-type: x mandatory");
|
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 workflowColumnsBlock = extractRuleBlock(baseCss, ".board.board-workflow-columns");
|
||||||
const laneColumnsBlock = extractRuleBlock(baseCss, ".lane-columns");
|
const laneColumnsBlock = extractRuleBlock(baseCss, ".lane-columns");
|
||||||
|
|
||||||
for (const block of [workflowColumnsBlock, laneColumnsBlock]) {
|
for (const block of [workflowColumnsBlock, laneColumnsBlock]) {
|
||||||
expect(block).toContain("overflow-x: auto");
|
expect(block).toContain("overflow-x: auto");
|
||||||
expect(block).toContain("overscroll-behavior-x: contain");
|
expect(block).toContain("overscroll-behavior-x: contain");
|
||||||
expect(block).toContain("scroll-snap-type: x proximity");
|
expect(block).toContain("scroll-snap-type: none");
|
||||||
expect(block).not.toContain("scroll-snap-type: x mandatory");
|
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/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { loadAllAppCss } from "../test/cssFixture";
|
import { loadAllAppCss } from "../test/cssFixture";
|
||||||
import { readFileSync } from "fs";
|
|
||||||
import { resolve } from "path";
|
|
||||||
|
|
||||||
const css = loadAllAppCss();
|
const css = loadAllAppCss();
|
||||||
|
|
||||||
describe("scroll-snap CSS", () => {
|
describe("scroll-snap CSS", () => {
|
||||||
describe("base (desktop) styles", () => {
|
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", () => {
|
it("contains scroll-snap-align: center on .column", () => {
|
||||||
|
|||||||
@@ -57,7 +57,12 @@
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
overscroll-behavior-x: contain;
|
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;
|
touch-action: pan-x pan-y;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +130,8 @@
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
overscroll-behavior-x: contain;
|
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-color: var(--border) transparent;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -184,6 +190,19 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
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;
|
scroll-snap-type: x proximity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { act, renderHook } from "@testing-library/react";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
isColumnCentered,
|
isColumnCentered,
|
||||||
|
resolveFlingTargetIndex,
|
||||||
|
resolvePageAnimationMs,
|
||||||
|
resolvePageCount,
|
||||||
resolvePanDirection,
|
resolvePanDirection,
|
||||||
resolveSettleTargetIndex,
|
resolveSettleTargetIndex,
|
||||||
useColumnScrollSnap,
|
useColumnScrollSnap,
|
||||||
@@ -95,9 +98,17 @@ function dispatchShortSwipe(
|
|||||||
dispatchPointerEvent(scroller, "pointerup", 200 - clientDelta);
|
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 {
|
function settleAfterMomentum(): void {
|
||||||
act(() => {
|
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", () => {
|
describe("isColumnCentered", () => {
|
||||||
it("recognizes only an integer column-centering target", () => {
|
it("recognizes only an integer column-centering target", () => {
|
||||||
const scroller = createScroller(3, COLUMN_WIDTH);
|
const scroller = createScroller(3, COLUMN_WIDTH);
|
||||||
@@ -261,7 +339,13 @@ describe("useColumnScrollSnap", () => {
|
|||||||
expect(scroller.scrollLeft).toBe(0);
|
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();
|
const scroller = createScroller();
|
||||||
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
|
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
|
||||||
|
|
||||||
@@ -270,15 +354,39 @@ describe("useColumnScrollSnap", () => {
|
|||||||
scroller.scrollLeft = 40;
|
scroller.scrollLeft = 40;
|
||||||
scroller.dispatchEvent(new Event("scroll"));
|
scroller.dispatchEvent(new Event("scroll"));
|
||||||
dispatchPointerEvent(scroller, "pointermove", 160);
|
dispatchPointerEvent(scroller, "pointermove", 160);
|
||||||
dispatchPointerEvent(scroller, "pointerup", 160);
|
|
||||||
});
|
});
|
||||||
|
// Finger still down: the board rests wherever it was dragged.
|
||||||
expect(scroller.scrollLeft).toBe(40);
|
expect(scroller.scrollLeft).toBe(40);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
|
dispatchPointerEvent(scroller, "pointerup", 160);
|
||||||
|
// Residual compositor inertia tick arriving after lift, mid page animation.
|
||||||
scroller.scrollLeft = 70;
|
scroller.scrollLeft = 70;
|
||||||
scroller.dispatchEvent(new Event("scroll"));
|
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();
|
settleAfterMomentum();
|
||||||
expect(scroller.scrollLeft).toBe(COLUMN_WIDTH);
|
expect(scroller.scrollLeft).toBe(COLUMN_WIDTH);
|
||||||
@@ -308,36 +416,43 @@ describe("useColumnScrollSnap", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:BoardNavigation 2026-07-22-21:40:
|
FNXC:BoardNavigation 2026-07-22-21:40 (reframed 2026-07-24-11:20):
|
||||||
Tap-to-stop during momentum, then drag: the new drag's landing point must win. The
|
The corrective seam used to be tap-to-stop during native momentum; owning the momentum replaces
|
||||||
commit-one-column clamp only applies to gestures that began centered at rest — from a
|
that long coast with a ~200ms animation, so the equivalent guard is a re-touch DURING the page
|
||||||
mid-transit origin it forced a page past the corrective drag.
|
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);
|
const scroller = createScroller(3, 0);
|
||||||
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
|
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
|
||||||
|
|
||||||
act(() => {
|
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, "pointerdown", 200);
|
||||||
dispatchPointerEvent(scroller, "pointermove", 160);
|
dispatchPointerEvent(scroller, "pointermove", 160);
|
||||||
scroller.scrollLeft = 30;
|
scroller.scrollLeft = 30;
|
||||||
scroller.dispatchEvent(new Event("scroll"));
|
scroller.dispatchEvent(new Event("scroll"));
|
||||||
dispatchPointerEvent(scroller, "pointerup", 160);
|
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, "pointerdown", 150);
|
||||||
dispatchPointerEvent(scroller, "pointermove", 180);
|
dispatchPointerEvent(scroller, "pointermove", 190);
|
||||||
scroller.scrollLeft = 100;
|
scroller.scrollLeft = 60;
|
||||||
scroller.dispatchEvent(new Event("scroll"));
|
scroller.dispatchEvent(new Event("scroll"));
|
||||||
dispatchPointerEvent(scroller, "pointerup", 180);
|
dispatchPointerEvent(scroller, "pointerup", 190);
|
||||||
});
|
});
|
||||||
settleAfterMomentum();
|
settleAfterMomentum();
|
||||||
|
|
||||||
// Regression: the min-progress clamp previously forced column 0 (scrollLeft 0).
|
// The leftward corrective drag wins: back to column 0, never onward to column 1.
|
||||||
expect(scroller.scrollLeft).toBe(COLUMN_WIDTH);
|
expect(scroller.scrollLeft).toBe(0);
|
||||||
expect(isColumnCentered(scroller, [...scroller.children] as HTMLElement[])).toBe(true);
|
expect(isColumnCentered(scroller, [...scroller.children] as HTMLElement[])).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -384,12 +499,14 @@ describe("useColumnScrollSnap", () => {
|
|||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
dispatchShortSwipe(scroller, { scrollDelta: 10, clientDelta: 20 });
|
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"));
|
scroller.dispatchEvent(new Event("scrollend"));
|
||||||
expect(scroller.scrollLeft).toBe(COLUMN_WIDTH);
|
|
||||||
|
|
||||||
// Let multiple watchdog passes complete before the callback-less compositor write.
|
// Run the page animation out, then let watchdog passes complete before the
|
||||||
vi.advanceTimersByTime(48);
|
// callback-less compositor write.
|
||||||
|
vi.advanceTimersByTime(SETTLE_ADVANCE_MS);
|
||||||
|
expect(scroller.scrollLeft).toBe(COLUMN_WIDTH);
|
||||||
scroller.scrollLeft = COLUMN_WIDTH + 40;
|
scroller.scrollLeft = COLUMN_WIDTH + 40;
|
||||||
vi.advanceTimersByTime(16);
|
vi.advanceTimersByTime(16);
|
||||||
});
|
});
|
||||||
@@ -399,7 +516,12 @@ describe("useColumnScrollSnap", () => {
|
|||||||
expect(isColumnCentered(scroller, columns)).toBe(true);
|
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();
|
const scroller = createScroller();
|
||||||
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
|
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
|
||||||
|
|
||||||
@@ -408,11 +530,13 @@ describe("useColumnScrollSnap", () => {
|
|||||||
dispatchPointerEvent(scroller, "pointermove", 170);
|
dispatchPointerEvent(scroller, "pointermove", 170);
|
||||||
scroller.scrollLeft = 25;
|
scroller.scrollLeft = 25;
|
||||||
scroller.dispatchEvent(new Event("scroll"));
|
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);
|
expect(scroller.scrollLeft).toBe(25);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
scroller.dispatchEvent(new Event("touchcancel"));
|
||||||
|
});
|
||||||
settleAfterMomentum();
|
settleAfterMomentum();
|
||||||
expect(scroller.scrollLeft).toBe(COLUMN_WIDTH);
|
expect(scroller.scrollLeft).toBe(COLUMN_WIDTH);
|
||||||
});
|
});
|
||||||
@@ -620,6 +744,107 @@ describe("useColumnScrollSnap", () => {
|
|||||||
expect(isColumnCentered(scroller, [...scroller.children] as HTMLElement[])).toBe(true);
|
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", () => {
|
it("does not attach on non-phone desktop", () => {
|
||||||
stubViewport("wide-short-desktop");
|
stubViewport("wide-short-desktop");
|
||||||
expect(isMobileViewport()).toBe(false);
|
expect(isMobileViewport()).toBe(false);
|
||||||
|
|||||||
@@ -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
|
cancelled swipe's directional page). Directional paging still applies only when the settle
|
||||||
gesture itself had pan intent.
|
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;
|
const CENTER_TOLERANCE_PX = 1;
|
||||||
/** Minimum finger travel to count as a horizontal pan (short swipe still commits). */
|
/** Minimum finger travel to count as a horizontal pan (short swipe still commits). */
|
||||||
const MIN_PAN_CLIENT_PX = 12;
|
const MIN_PAN_CLIENT_PX = 12;
|
||||||
/** Keep a WebKit compositor write from outliving the main-thread hard jump. */
|
/** Keep a WebKit compositor write from outliving the main-thread hard jump. */
|
||||||
const PIN_REASSERT_INTERVAL_MS = 16;
|
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 {
|
export interface UseColumnScrollSnapOptions {
|
||||||
/** Restrict magnetic snapping to phone-class viewports. */
|
/** Restrict magnetic snapping to phone-class viewports. */
|
||||||
mobileOnly?: boolean;
|
mobileOnly?: boolean;
|
||||||
@@ -282,6 +388,19 @@ export function useColumnScrollSnap(
|
|||||||
let pinnedScrollLeft: number | null = null;
|
let pinnedScrollLeft: number | null = null;
|
||||||
/** Continues correcting late WebKit compositor writes until the next user interaction. */
|
/** Continues correcting late WebKit compositor writes until the next user interaction. */
|
||||||
let pinReassertTimer: ReturnType<typeof setTimeout> | null = null;
|
let pinReassertTimer: ReturnType<typeof setTimeout> | 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 = () => {
|
const clearIdleTimer = () => {
|
||||||
if (idleTimer !== null) clearTimeout(idleTimer);
|
if (idleTimer !== null) clearTimeout(idleTimer);
|
||||||
@@ -369,6 +488,95 @@ export function useColumnScrollSnap(
|
|||||||
reassertPinnedScrollLeft();
|
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:
|
* FNXC:BoardNavigation 2026-07-22-15:26:
|
||||||
* Hard-jump to the nearest column center when off-center. Returns true when a snap
|
* 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);
|
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:
|
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.
|
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;
|
if (event.type === "touchstart") touchSequenceActive = true;
|
||||||
clearPin();
|
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.
|
// Mid-momentum re-touch (or duplicate pointerdown+touchstart): cancel pending snap and re-baseline.
|
||||||
if (interactionActive) {
|
if (interactionActive) {
|
||||||
@@ -593,8 +881,12 @@ export function useColumnScrollSnap(
|
|||||||
lastScrollLeft = current;
|
lastScrollLeft = current;
|
||||||
markMoved();
|
markMoved();
|
||||||
|
|
||||||
// While finger is down: free-scroll only. After lift: re-arm idle (momentum).
|
// While finger is down: free-scroll only, sampling speed for the release page count.
|
||||||
if (pointerHeld) return;
|
if (pointerHeld) {
|
||||||
|
sampleVelocity(current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Post-lift ticks (residual inertia before our page takes over): keep the fallback armed.
|
||||||
armIdleSettle();
|
armIdleSettle();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -614,6 +906,16 @@ export function useColumnScrollSnap(
|
|||||||
snapInScrollDirection();
|
snapInScrollDirection();
|
||||||
return;
|
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();
|
armIdleSettle();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -634,7 +936,10 @@ export function useColumnScrollSnap(
|
|||||||
pointerHeld = false;
|
pointerHeld = false;
|
||||||
releasePointerCapture();
|
releasePointerCapture();
|
||||||
lockDirectionFromGesture();
|
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();
|
armIdleSettle();
|
||||||
} else {
|
} else {
|
||||||
// FNXC:BoardNavigation 2026-07-22-15:26: Cancelled zero-pan touch must not leave mid-column.
|
// FNXC:BoardNavigation 2026-07-22-15:26: Cancelled zero-pan touch must not leave mid-column.
|
||||||
@@ -668,6 +973,8 @@ export function useColumnScrollSnap(
|
|||||||
return () => {
|
return () => {
|
||||||
clearIdleTimer();
|
clearIdleTimer();
|
||||||
clearPin();
|
clearPin();
|
||||||
|
// Unmount mid-animation must not leave the scroller frozen at `overflow-x: hidden`.
|
||||||
|
cancelPageAnimation();
|
||||||
releasePointerCapture();
|
releasePointerCapture();
|
||||||
restoreNativeSnap();
|
restoreNativeSnap();
|
||||||
scroller.removeEventListener("pointerdown", beginInteraction);
|
scroller.removeEventListener("pointerdown", beginInteraction);
|
||||||
|
|||||||
@@ -1130,6 +1130,14 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* === Board === */
|
/* === 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 {
|
.board {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -1143,7 +1151,7 @@ body {
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
overscroll-behavior-x: contain;
|
overscroll-behavior-x: contain;
|
||||||
scroll-snap-type: x proximity;
|
scroll-snap-type: none;
|
||||||
scroll-padding-inline: 50%;
|
scroll-padding-inline: 50%;
|
||||||
scrollbar-color: var(--border) transparent;
|
scrollbar-color: var(--border) transparent;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
|
|||||||
Reference in New Issue
Block a user