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:
gsxdsm
2026-07-24 22:16:11 -07:00
parent 4b4df1ff30
commit e01dc7d3d3
8 changed files with 641 additions and 50 deletions

View 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).

View File

@@ -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");
}); });
}); });

View File

@@ -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/);
});
}); });

View File

@@ -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", () => {

View File

@@ -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;
} }
} }

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;