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*;/);
|
||||
}
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<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 = () => {
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user