FN-8235: add mobile Kanban column snapping

Mobile Kanban now settles user swipes on the nearest column.

- add a mobile-only, user-driven scroll-end snapping hook
- wire snapping to every live Board rendering and cover interaction boundaries
- document the behavior and add a patch changeset

Files changed:
 .changeset/fn-8235-mobile-kanban-snap.md           |   7 +
 docs/dashboard-guide.md                            |   6 +
 packages/dashboard/app/components/Board.tsx        |  19 ++-
 .../app/components/__tests__/board-mobile.test.tsx |  14 +-
 .../hooks/__tests__/useColumnScrollSnap.test.ts    | 159 ++++++++++++++++++
 .../dashboard/app/hooks/useColumnScrollSnap.ts     | 179 +++++++++++++++++++++
 6 files changed, 380 insertions(+), 4 deletions(-)

Fusion-Task-Id: FN-8235

Fusion-Task-Lineage: 46881e93-59f2-49d6-b29e-fa6150420f2a

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-17 13:53:13 -07:00
parent 44c784264a
commit 595c27ceae
6 changed files with 380 additions and 4 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Mobile Kanban board now magnetically snaps to a single column when you swipe between columns.
category: fix
dev: New app/hooks/useColumnScrollSnap.ts scroll-end snap wired to Board #board; keeps CSS scroll-snap-type: x proximity (no mandatory) to preserve the FN-001 corner-rendering fix.

View File

@@ -80,6 +80,12 @@ On mobile, an open navigation-bar **More** sheet or mailbox message detail is di
When task detail is open from a board card, task popup, mobile list row, right-dock/activity/onboarding link, deep link, or another task detail link, one browser, iOS edge-swipe, or Android Back action closes the current detail first and restores the prior dashboard context (for example, nested task detail → previous task detail, or task detail → board/list).
<!-- FNXC:TaskDetailSwipeBackDocs 2026-07-15-10:36: Mobile task popups now register the same navigation entry as modal and full-panel task detail, so every Back delivery mechanism dismisses the popup before it can leave the originating Board or List. -->
On mobile board-card detail, **Back to board** also restores the prior board/card scroll position so the same lane context remains visible.
### Mobile Kanban column snapping
After a horizontal swipe on the mobile Kanban board, Fusion smoothly settles the viewport on the nearest column so it does not rest between two columns. This is a user-scroll-end behavior only; refreshes, resizes, and restored pages preserve the column position you chose. The board intentionally keeps CSS `scroll-snap-type: x proximity` rather than using `x mandatory`, because mandatory snapping reintroduced the FN-001 iOS corner-rendering regression during layout changes.
<!-- FNXC:BoardNavigationDocs 2026-07-15-13:30: Mobile Kanban documentation must describe the user-only JS scroll-end snap and its FN-001 proximity-CSS rationale so operators understand why layout changes never force a column. -->
<!-- FNXC:BoardNavigationDocs 2026-06-29-20:45: Mobile full-panel task detail temporarily replaces the board, so the user-facing navigation guide must document that Back to board restores the board/card scroll context instead of returning to the top of the board. -->
This behavior used to be mobile-only, and now applies across all viewports.
Task Detail modal opens from onboarding, activity log, and task-to-task navigation now all register navigation history entries, so Android back swipe/button dismisses them consistently.

View File

@@ -9,6 +9,7 @@ import { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { promoteTask, type ModelInfo, type BoardWorkflowsPayload, type BoardWorkflowColumn, type RevertTaskOptions, type RevertTaskResult } from "../api";
import { useBlockerFanout } from "../hooks/useBlockerFanout";
import { useColumnScrollSnap } from "../hooks/useColumnScrollSnap";
import { MOBILE_MEDIA_QUERY, useViewportMode } from "../hooks/useViewportMode";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
import { getBoardCanDropTaskRejection } from "./boardCanDropTask";
@@ -178,6 +179,18 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
);
const archivedLoadedRef = useRef(false);
const boardRef = useRef<HTMLElement | null>(null);
const [boardElement, setBoardElement] = useState<HTMLElement | null>(null);
/*
FNXC:BoardNavigation 2026-07-16-00:00:
The board can first render a workflow-loading skeleton, so a mutable ref alone would not
re-run the snap effect after the live board mounts. Mirror the callback ref in state to attach
user-only mobile snapping to every live Board variant without snapping the skeleton.
*/
const setBoardRef = useCallback((element: HTMLElement | null) => {
boardRef.current = element;
setBoardElement((current) => current === element ? current : element);
}, []);
useColumnScrollSnap(boardElement, { mobileOnly: true });
const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState<HTMLElement | null>(() => {
if (typeof document === "undefined") return null;
return document.getElementById("header-workflow-slot");
@@ -927,7 +940,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
return (
<div className="board-workflow-view">
{renderedWorkflowToolbar}
<main className="board board-workflow-columns" id="board" ref={boardRef}>
<main className="board board-workflow-columns" id="board" ref={setBoardRef}>
{aggregateRenderedBoardColumns.map((columnDef) => {
const isCreateColumn = aggregateQuickCreateTarget?.columnId === columnDef.id;
const isDoneLikeColumn = columnDef.flags.complete === true && columnDef.flags.archived !== true;
@@ -999,7 +1012,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
<main
className="board board-workflow-columns"
id="board"
ref={boardRef}
ref={setBoardRef}
onDragStart={(e) => {
const id = (e.target as HTMLElement)?.closest?.("[data-id]")?.getAttribute("data-id");
if (id) draggingTaskIdRef.current = id;
@@ -1134,7 +1147,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
return (
<>
<main className="board" id="board" ref={boardRef}>
<main className="board" id="board" ref={setBoardRef}>
{COLUMNS.map((col) => (
<Column
key={col}

View File

@@ -89,7 +89,19 @@ function getMainMobileSection(css: string): string {
return blocks.join("\n");
}
describe("getMainMobileSection — compound media queries", () => {
describe("mobile board magnetic column snap wiring (FN-8235)", () => {
it("shares the mobile scroll-end hook across legacy, selected, and aggregate live board renders", () => {
const boardSource = fs.readFileSync(path.join(process.cwd(), "app/components/Board.tsx"), "utf8");
expect(boardSource).toContain('import { useColumnScrollSnap } from "../hooks/useColumnScrollSnap";');
expect(boardSource).toContain("useColumnScrollSnap(boardElement, { mobileOnly: true });");
expect(boardSource.match(/ref=\{setBoardRef\}/g)).toHaveLength(3);
expect(boardSource.match(/className="board board-workflow-columns"/g)).toHaveLength(2);
expect(boardSource).toContain('<main className="board" id="board" ref={setBoardRef}>');
});
});
describe("getMainMobileSection — compound media queries", () => {
it("matches simple and compound 768px blocks but excludes other breakpoints", () => {
const syntheticCss = `
@media (max-width: 768px)

View File

@@ -0,0 +1,159 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useColumnScrollSnap } from "../useColumnScrollSnap";
import { isMobileViewport } from "../useViewportMode";
type Viewport = "mobile" | "wide-short-desktop";
function stubViewport(viewport: Viewport): void {
const isMobile = viewport === "mobile";
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
matches: query === "(max-width: 768px)" ? isMobile : query === "(max-height: 480px)",
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(() => true),
})));
Object.defineProperty(window, "screen", {
configurable: true,
value: viewport === "mobile" ? { width: 390, height: 844 } : { width: 1920, height: 1080 },
});
Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: viewport === "mobile" ? 1 : 0 });
vi.stubGlobal("visualViewport", {
width: viewport === "mobile" ? 390 : 1200,
height: viewport === "mobile" ? 844 : 400,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
}
function createScroller(columnCount = 2): HTMLElement {
const scroller = document.createElement("main");
Object.defineProperty(scroller, "clientWidth", { configurable: true, value: 100 });
scroller.getBoundingClientRect = () => new DOMRect(0, 0, 100, 200);
Object.defineProperty(scroller, "scrollTo", { configurable: true, value: vi.fn() });
for (let index = 0; index < columnCount; index++) {
const column = document.createElement("section");
const left = index === 0 ? -60 : 30;
column.getBoundingClientRect = () => new DOMRect(left, 0, 100, 200);
scroller.append(column);
}
document.body.append(scroller);
return scroller;
}
function dispatchUserPan(scroller: HTMLElement): void {
scroller.dispatchEvent(new Event("pointerdown"));
scroller.scrollLeft = 10;
scroller.dispatchEvent(new Event("scroll"));
scroller.dispatchEvent(new Event("scrollend"));
}
describe("useColumnScrollSnap", () => {
beforeEach(() => {
vi.useFakeTimers();
stubViewport("mobile");
});
afterEach(() => {
document.body.replaceChildren();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("centers the nearest column after verified user horizontal movement ends", () => {
const scroller = createScroller();
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
act(() => dispatchUserPan(scroller));
expect(scroller.scrollTo).toHaveBeenCalledWith({ left: 40, behavior: "smooth" });
});
it("attaches after a loading skeleton is replaced by the live board", () => {
const scroller = createScroller();
const { rerender } = renderHook(
({ element }) => useColumnScrollSnap(element, { mobileOnly: true, isUserInteraction: () => true }),
{ initialProps: { element: null as HTMLElement | null } },
);
rerender({ element: scroller });
act(() => dispatchUserPan(scroller));
expect(scroller.scrollTo).toHaveBeenCalledWith({ left: 40, behavior: "smooth" });
});
it("does not snap on mount, viewport lifecycle events, or programmatic scrolling", () => {
const scroller = createScroller();
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
act(() => {
window.dispatchEvent(new Event("resize"));
window.dispatchEvent(new Event("pageshow"));
scroller.scrollLeft = 40;
scroller.dispatchEvent(new Event("scroll"));
scroller.dispatchEvent(new Event("scrollend"));
vi.advanceTimersByTime(500);
});
expect(scroller.scrollTo).not.toHaveBeenCalled();
});
it("requires horizontal movement after user input rather than a recent tap", () => {
const scroller = createScroller();
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
act(() => {
scroller.dispatchEvent(new Event("pointerdown"));
scroller.dispatchEvent(new Event("scrollend"));
vi.advanceTimersByTime(500);
});
expect(scroller.scrollTo).not.toHaveBeenCalled();
});
it.each([0, 1])("does nothing with %s snap children", (columnCount) => {
const scroller = createScroller(columnCount);
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
act(() => dispatchUserPan(scroller));
expect(scroller.scrollTo).not.toHaveBeenCalled();
});
it("does not attach magnetic snapping on a wide, short non-phone desktop", () => {
stubViewport("wide-short-desktop");
expect(window.screen.width).toBe(1920);
expect(window.screen.height).toBe(1080);
expect(window.visualViewport?.width).toBe(1200);
expect(window.matchMedia("(max-width: 768px)").matches).toBe(false);
expect(window.matchMedia("(max-height: 480px)").matches).toBe(true);
expect(isMobileViewport()).toBe(false);
const scroller = createScroller();
const addListener = vi.spyOn(scroller, "addEventListener");
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
act(() => dispatchUserPan(scroller));
expect(addListener).not.toHaveBeenCalledWith("scrollend", expect.any(Function));
expect(scroller.scrollTo).not.toHaveBeenCalled();
});
it("ignores scroll activity while its own smooth snap is in progress", () => {
const scroller = createScroller();
renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true }));
act(() => {
dispatchUserPan(scroller);
scroller.dispatchEvent(new Event("scroll"));
scroller.dispatchEvent(new Event("scrollend"));
dispatchUserPan(scroller);
});
expect(scroller.scrollTo).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,179 @@
import { useEffect, useState } from "react";
import { isMobileViewport } from "./useViewportMode";
const SCROLL_IDLE_DELAY_MS = 120;
const SNAP_RELEASE_DELAY_MS = 300;
const CENTER_TOLERANCE_PX = 1;
export interface UseColumnScrollSnapOptions {
/** Restrict magnetic snapping to phone-class viewports. */
mobileOnly?: boolean;
/** Test seam; production callers must use the default trusted-event predicate. */
isUserInteraction?: (event: Event) => boolean;
}
function defaultIsUserInteraction(event: Event): boolean {
return event.isTrusted;
}
function addMediaChangeListener(query: MediaQueryList, listener: () => void): () => void {
if (typeof query.addEventListener === "function") {
query.addEventListener("change", listener);
return () => query.removeEventListener("change", listener);
}
query.addListener(listener);
return () => query.removeListener(listener);
}
/**
* Adds a mobile-only, user-driven scroll-end snap to a horizontal column scroller.
*
* FNXC:BoardNavigation 2026-07-15-13:30:
* Mobile Kanban swipes must settle on one column, but CSS `scroll-snap-type: x mandatory`
* regressed FN-001 by snapping against stale iOS layout metrics. Preserve proximity CSS and
* snap only after verified user horizontal movement has ended; mount, reflow, resize, pageshow,
* and programmatic scrolling must never choose a board column.
*/
export function useColumnScrollSnap(
scroller: HTMLElement | null,
{ mobileOnly = false, isUserInteraction = defaultIsUserInteraction }: UseColumnScrollSnapOptions = {},
): void {
const [isEligibleViewport, setIsEligibleViewport] = useState(() => !mobileOnly || isMobileViewport());
useEffect(() => {
if (!mobileOnly || typeof window === "undefined") return;
const updateEligibility = () => setIsEligibleViewport(isMobileViewport());
const widthQuery = window.matchMedia("(max-width: 768px)");
const heightQuery = window.matchMedia("(max-height: 480px)");
const removeWidthListener = addMediaChangeListener(widthQuery, updateEligibility);
const removeHeightListener = addMediaChangeListener(heightQuery, updateEligibility);
const visualViewport = window.visualViewport;
window.addEventListener("resize", updateEligibility);
window.addEventListener("orientationchange", updateEligibility);
visualViewport?.addEventListener("resize", updateEligibility);
updateEligibility();
return () => {
removeWidthListener();
removeHeightListener();
window.removeEventListener("resize", updateEligibility);
window.removeEventListener("orientationchange", updateEligibility);
visualViewport?.removeEventListener("resize", updateEligibility);
};
}, [mobileOnly]);
useEffect(() => {
if (!scroller || !isEligibleViewport) return;
let interactionActive = false;
let interactionScrollLeft = scroller.scrollLeft;
let sawHorizontalMovement = false;
let isSnapping = false;
let idleTimer: ReturnType<typeof setTimeout> | null = null;
let snapReleaseTimer: ReturnType<typeof setTimeout> | null = null;
const clearIdleTimer = () => {
if (idleTimer !== null) clearTimeout(idleTimer);
idleTimer = null;
};
const releaseSnap = () => {
if (snapReleaseTimer !== null) clearTimeout(snapReleaseTimer);
snapReleaseTimer = setTimeout(() => {
isSnapping = false;
snapReleaseTimer = null;
}, SNAP_RELEASE_DELAY_MS);
};
const snapToNearestColumn = () => {
clearIdleTimer();
if (!interactionActive || !sawHorizontalMovement || isSnapping) return;
interactionActive = false;
sawHorizontalMovement = false;
const columns = Array.from(scroller.children) as HTMLElement[];
if (columns.length < 2) return;
const scrollerRect = scroller.getBoundingClientRect();
const viewportWidth = scroller.clientWidth || scrollerRect.width;
if (viewportWidth <= 0) return;
const viewportCenter = scrollerRect.left + viewportWidth / 2;
let nearestColumn: HTMLElement | null = null;
let nearestDistance = Number.POSITIVE_INFINITY;
for (const column of columns) {
const rect = column.getBoundingClientRect();
const distance = Math.abs(rect.left + rect.width / 2 - viewportCenter);
if (distance < nearestDistance) {
nearestColumn = column;
nearestDistance = distance;
}
}
if (!nearestColumn || nearestDistance <= CENTER_TOLERANCE_PX) return;
const columnRect = nearestColumn.getBoundingClientRect();
const targetLeft = scroller.scrollLeft + columnRect.left + columnRect.width / 2 - viewportCenter;
isSnapping = true;
if (typeof scroller.scrollTo === "function") {
scroller.scrollTo({ left: targetLeft, behavior: "smooth" });
} else {
scroller.scrollLeft = targetLeft;
}
releaseSnap();
};
const beginInteraction = (event: Event) => {
if (isSnapping || !isUserInteraction(event)) return;
interactionActive = true;
sawHorizontalMovement = false;
interactionScrollLeft = scroller.scrollLeft;
};
const handleScroll = () => {
if (isSnapping || !interactionActive) return;
if (scroller.scrollLeft === interactionScrollLeft) return;
sawHorizontalMovement = true;
interactionScrollLeft = scroller.scrollLeft;
clearIdleTimer();
idleTimer = setTimeout(snapToNearestColumn, SCROLL_IDLE_DELAY_MS);
};
const handleInteractionEnd = () => {
if (!interactionActive || !sawHorizontalMovement || isSnapping) return;
clearIdleTimer();
idleTimer = setTimeout(snapToNearestColumn, SCROLL_IDLE_DELAY_MS);
};
const handleScrollEnd = () => {
if (isSnapping) {
isSnapping = false;
if (snapReleaseTimer !== null) clearTimeout(snapReleaseTimer);
snapReleaseTimer = null;
return;
}
snapToNearestColumn();
};
scroller.addEventListener("pointerdown", beginInteraction);
scroller.addEventListener("touchstart", beginInteraction);
scroller.addEventListener("wheel", beginInteraction, { passive: true });
scroller.addEventListener("scroll", handleScroll, { passive: true });
scroller.addEventListener("scrollend", handleScrollEnd);
scroller.addEventListener("pointerup", handleInteractionEnd);
scroller.addEventListener("touchend", handleInteractionEnd);
return () => {
clearIdleTimer();
if (snapReleaseTimer !== null) clearTimeout(snapReleaseTimer);
scroller.removeEventListener("pointerdown", beginInteraction);
scroller.removeEventListener("touchstart", beginInteraction);
scroller.removeEventListener("wheel", beginInteraction);
scroller.removeEventListener("scroll", handleScroll);
scroller.removeEventListener("scrollend", handleScrollEnd);
scroller.removeEventListener("pointerup", handleInteractionEnd);
scroller.removeEventListener("touchend", handleInteractionEnd);
};
}, [isEligibleViewport, isUserInteraction, scroller]);
}