diff --git a/.changeset/fn-115-restore-board-card-click.md b/.changeset/fn-115-restore-board-card-click.md
new file mode 100644
index 0000000000..96bb9464e6
--- /dev/null
+++ b/.changeset/fn-115-restore-board-card-click.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Restore task-detail opening when clicking Board task cards.
+category: fix
+dev: Defers Board pointer capture until horizontal pan intent is established.
diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md
index 1b2248205c..a1cb8ddacf 100644
--- a/docs/dashboard-guide.md
+++ b/docs/dashboard-guide.md
@@ -168,8 +168,8 @@ On mobile board-card detail, **Back to board** also restores the prior board/car
### Board panning and mobile Kanban column snapping
-
-On desktop and tablet, drag horizontally from an empty Board surface or a task card's noninteractive body or text to pan the Board viewport. The gesture never moves a task: card controls, links, fields, editing, context menus, and file drops keep their normal behavior, and **Move to** in the task context menu remains the only relocation path. Fusion does not auto-scroll at Board edges. Phone touch scrolling and column snapping are unchanged.
+
+On desktop and tablet, an ordinary task-card click opens the configured task-detail destination (popup, right dock, or main panel). Drag horizontally from an empty Board surface or a task card's noninteractive body or text to pan the Board viewport only after the gesture crosses horizontal intent; that pan suppresses its compatibility click and does not open detail. The gesture never moves a task: card controls, links, fields, editing, context menus, and file drops keep their normal behavior, and **Move to** in the task context menu remains the only relocation path. Fusion does not auto-scroll at Board edges. Phone touch scrolling and column snapping are unchanged.
On the mobile Kanban board, free-scroll while your finger is down. At release, Fusion resolves the existing directional or nearest-column target and moves continuously to that exact reachable column, so the board feels fluid instead of abruptly locking or visibly hard-jumping. Direction is locked at finger-up from the net swipe (not rubber-band ticks), and the result pins until the next touch. A direction-zero or off-center release uses the same controlled nearest-column settle, so the board never rests between columns. Reduced-motion preferences, negligible-distance corrections, or unavailable animation capability use an immediate safe landing instead.
@@ -334,8 +334,8 @@ Features:
- Move a task from its context menu: right-click on desktop, use the overflow button or keyboard Context Menu/Shift+F10, or long-press on touch. When several legal destinations exist, open the grouped **Move to** submenu; a single destination remains a direct action. This changes task movement only.
-
-- On desktop and tablet, click-hold and drag a safe Board surface, including empty-column text, to pan columns by actual pointer movement. Task cards, controls, editable content, and native-draggable targets retain their normal behavior; approaching an edge never auto-scrolls. Native wheel, trackpad, scrollbar, and keyboard scrolling remain available. Phones continue to use unchanged native touch scrolling and mobile-only column snapping.
+
+- On desktop and tablet, click-hold and drag a safe Board surface, including empty-column text or a task card's noninteractive body/text, to pan columns by actual pointer movement after horizontal intent. An ordinary task-card click opens its configured detail destination; a qualifying pan suppresses only its compatibility click. Controls, editable content, and native-draggable targets retain their normal behavior; approaching an edge never auto-scrolls. Native wheel, trackpad, scrollbar, and keyboard scrolling remain available. Phones continue to use unchanged native touch scrolling and mobile-only column snapping.
- Search/filter tasks (including working-branch and base-branch dropdown filters with explicit **No working branch** / **No base branch** options)
- Working-branch and base-branch filter selections are persisted per project and restored across refresh/navigation
- Column visibility controls
diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx
index 8a7e391f5c..6914eaa9c1 100644
--- a/packages/dashboard/app/components/Board.tsx
+++ b/packages/dashboard/app/components/Board.tsx
@@ -231,10 +231,11 @@ export function Board({ tasks, projectId, maxConcurrent, effectiveMaxConcurrent
const mobileFullTaskModalHidden = viewportMode === "mobile";
useColumnScrollSnap(boardElement, { mobileOnly: true });
/*
- FNXC:BoardNavigation 2026-08-21-16:03:
- FN-109 shares one non-mobile primary-mouse pan owner across selected and All-workflows live roots.
- Empty surfaces plus noninteractive task-card bodies/text pan only the viewport; controls and editing
- stay native, task relocation stays menu-only, the skeleton stays unbound, and mobile snap ownership is unchanged.
+ FNXC:BoardNavigation 2026-08-21-18:12:
+ FN-115 keeps the shared non-mobile mouse-pan owner on both live Board roots, but card activation
+ remains native until horizontal intent is proven. A real pan captures and consumes its compatibility
+ click; stationary card bodies/text retain their configured detail route, while controls, editing,
+ the skeleton, and mobile snap ownership remain unchanged.
*/
const { isPanning: isBoardMousePanning, ...boardMousePanBindings } = useBoardMousePan(boardElement, viewportMode !== "mobile");
const boardClassName = `board board-workflow-columns${isBoardMousePanning ? " is-mouse-panning" : ""}`;
diff --git a/packages/dashboard/app/components/__tests__/Board.test.tsx b/packages/dashboard/app/components/__tests__/Board.test.tsx
index abe0450771..a14d204788 100644
--- a/packages/dashboard/app/components/__tests__/Board.test.tsx
+++ b/packages/dashboard/app/components/__tests__/Board.test.tsx
@@ -249,6 +249,8 @@ function renderBoard(props = {}) {
function makeBoardHorizontallyScrollable(board: HTMLElement, scrollLeft = 100) {
Object.defineProperty(board, "clientWidth", { configurable: true, value: 200 });
Object.defineProperty(board, "scrollWidth", { configurable: true, value: 600 });
+ // FNXC:BoardNavigation 2026-08-21-18:50: jsdom has no native pointer capture; Chromium covers its delivery semantics.
+ Object.defineProperty(board, "setPointerCapture", { configurable: true, value: vi.fn() });
board.scrollLeft = scrollLeft;
}
@@ -2054,24 +2056,34 @@ describe("Board", () => {
makeBoardHorizontallyScrollable(selectedBoard);
const selectedCard = screen.getByTestId("board-task-card-FN-1");
/*
- FNXC:BoardNavigation 2026-08-21-16:03:
- FN-109 keeps native task movement retired. A noninteractive card body or title may only pan
- its owning Board; controls remain native and the Board capture guard consumes the following click.
+ FNXC:BoardNavigation 2026-08-21-18:12:
+ FN-115 requires a complete stationary pointer sequence to retain the card's native detail
+ activation. Only the horizontal drag path captures the Board and consumes its next click;
+ controls remain native and task movement stays menu-only.
*/
expect(selectedCard).not.toHaveAttribute("draggable");
+ const selectedCapture = vi.fn();
+ Object.defineProperty(selectedBoard, "setPointerCapture", { configurable: true, value: selectedCapture });
fireEvent.pointerDown(selectedCard, { button: 0, clientX: 100, clientY: 50, pointerId: 1, pointerType: "mouse" });
- fireEvent.pointerMove(selectedCard, { clientX: 40, clientY: 50, pointerId: 1, pointerType: "mouse" });
+ fireEvent.pointerUp(selectedCard, { pointerId: 1, pointerType: "mouse" });
+ fireEvent.click(selectedCard);
+ expect(selectedCapture).not.toHaveBeenCalled();
+ expect(onOpenDetail).toHaveBeenCalledTimes(1);
+
+ fireEvent.pointerDown(selectedCard, { button: 0, clientX: 100, clientY: 50, pointerId: 2, pointerType: "mouse" });
+ fireEvent.pointerMove(selectedCard, { clientX: 40, clientY: 50, pointerId: 2, pointerType: "mouse" });
+ expect(selectedCapture).toHaveBeenCalledWith(2);
expect(selectedBoard.scrollLeft).toBe(160);
expect(selectedBoard).toHaveClass("is-mouse-panning");
- fireEvent.pointerUp(selectedCard, { pointerId: 1, pointerType: "mouse" });
+ fireEvent.pointerUp(selectedCard, { pointerId: 2, pointerType: "mouse" });
expect(selectedBoard).not.toHaveClass("is-mouse-panning");
fireEvent.click(selectedCard);
- expect(onOpenDetail).not.toHaveBeenCalled();
+ expect(onOpenDetail).toHaveBeenCalledTimes(1);
expect(onMoveTask).not.toHaveBeenCalled();
expect(screen.getByTestId("column-todo")).toHaveAttribute("data-tasks", expect.stringContaining("FN-1"));
fireEvent.click(selectedCard);
- expect(onOpenDetail).toHaveBeenCalledTimes(1);
+ expect(onOpenDetail).toHaveBeenCalledTimes(2);
const selectedControl = screen.getByTestId("board-task-card-control-FN-1");
selectedBoard.scrollLeft = 100;
fireEvent.pointerDown(selectedControl, { button: 0, clientX: 100, clientY: 50, pointerId: 2, pointerType: "mouse" });
@@ -2079,28 +2091,33 @@ describe("Board", () => {
fireEvent.pointerUp(selectedControl, { pointerId: 2, pointerType: "mouse" });
fireEvent.click(selectedControl);
expect(selectedBoard.scrollLeft).toBe(100);
- expect(onOpenDetail).toHaveBeenCalledTimes(1);
+ expect(onOpenDetail).toHaveBeenCalledTimes(2);
await selectWorkflow(ALL_WORKFLOWS_BOARD_VIEW_ID);
const aggregateBoard = screen.getByRole("main") as HTMLElement;
makeBoardHorizontallyScrollable(aggregateBoard);
const aggregateTitle = screen.getByTestId("board-task-card-title-FN-1");
fireEvent.pointerDown(aggregateTitle, { button: 0, clientX: 100, clientY: 50, pointerId: 3, pointerType: "mouse" });
- fireEvent.pointerMove(aggregateTitle, { clientX: 40, clientY: 50, pointerId: 3, pointerType: "mouse" });
- expect(aggregateBoard.scrollLeft).toBe(160);
fireEvent.pointerUp(aggregateTitle, { pointerId: 3, pointerType: "mouse" });
+ fireEvent.click(aggregateTitle);
+ expect(onOpenDetail).toHaveBeenCalledTimes(3);
+
+ fireEvent.pointerDown(aggregateTitle, { button: 0, clientX: 100, clientY: 50, pointerId: 4, pointerType: "mouse" });
+ fireEvent.pointerMove(aggregateTitle, { clientX: 40, clientY: 50, pointerId: 4, pointerType: "mouse" });
+ expect(aggregateBoard.scrollLeft).toBe(160);
+ fireEvent.pointerUp(aggregateTitle, { pointerId: 4, pointerType: "mouse" });
expect(aggregateBoard).not.toHaveClass("is-mouse-panning");
fireEvent.click(aggregateTitle);
- expect(onOpenDetail).toHaveBeenCalledTimes(1);
+ expect(onOpenDetail).toHaveBeenCalledTimes(3);
expect(onMoveTask).not.toHaveBeenCalled();
fireEvent.click(aggregateTitle);
- expect(onOpenDetail).toHaveBeenCalledTimes(2);
+ expect(onOpenDetail).toHaveBeenCalledTimes(4);
const quickCreate = screen.getByTestId("mock-quick-create-triage");
- fireEvent.pointerDown(quickCreate, { button: 0, clientX: 100, clientY: 50, pointerId: 4, pointerType: "mouse" });
- fireEvent.pointerMove(quickCreate, { clientX: 40, clientY: 50, pointerId: 4, pointerType: "mouse" });
- fireEvent.pointerUp(quickCreate, { pointerId: 4, pointerType: "mouse" });
+ fireEvent.pointerDown(quickCreate, { button: 0, clientX: 100, clientY: 50, pointerId: 5, pointerType: "mouse" });
+ fireEvent.pointerMove(quickCreate, { clientX: 40, clientY: 50, pointerId: 5, pointerType: "mouse" });
+ fireEvent.pointerUp(quickCreate, { pointerId: 5, pointerType: "mouse" });
fireEvent.click(quickCreate);
expect(onQuickCreate).toHaveBeenCalledTimes(1);
});
diff --git a/packages/dashboard/app/components/__tests__/board-card-detail-locators.test.tsx b/packages/dashboard/app/components/__tests__/board-card-detail-locators.test.tsx
new file mode 100644
index 0000000000..8682252c53
--- /dev/null
+++ b/packages/dashboard/app/components/__tests__/board-card-detail-locators.test.tsx
@@ -0,0 +1,108 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { render } from "@testing-library/react";
+import type { Task } from "@fusion/core";
+import { Board } from "../Board";
+import { FloatingWindow } from "../FloatingWindow";
+import { TaskDetailContent } from "../TaskDetailModal";
+import { writeBoardWorkflowsCache } from "../../utils/boardWorkflowsCache";
+
+const noop = () => {};
+const noopAsync = async () => ({} as Task);
+
+const workflowPayload = {
+ flagEnabled: true,
+ defaultWorkflowId: "builtin:coding",
+ workflows: [{
+ id: "builtin:coding",
+ name: "Coding",
+ columns: [
+ { id: "triage", name: "Planning", flags: { intake: true } },
+ { id: "todo", name: "Todo", flags: { hold: true } },
+ { id: "in-progress", name: "In progress", flags: { countsTowardWip: true } },
+ { id: "in-review", name: "In review", flags: { mergeBlocker: true } },
+ { id: "done", name: "Done", flags: { complete: true } },
+ { id: "archived", name: "Archived", flags: { archived: true } },
+ ],
+ }],
+ taskWorkflowIds: { "FN-TITLE-FLICKER": "builtin:coding" },
+};
+
+const task = {
+ id: "FN-TITLE-FLICKER",
+ title: "Production card title",
+ description: "Production card description",
+ column: "todo",
+ status: "pending",
+ prompt: "",
+ steps: [],
+ attachments: [],
+ dependencies: [],
+ createdAt: "2026-08-21T00:00:00.000Z",
+ updatedAt: "2026-08-21T00:00:00.000Z",
+} as Task;
+
+beforeEach(() => {
+ window.localStorage.clear();
+ window.sessionStorage.clear();
+ writeBoardWorkflowsCache(undefined, workflowPayload);
+});
+
+/*
+FNXC:BoardNavigation 2026-08-21-23:02:
+FN-115's required Chromium lane uses production CSS selectors rather than mock-only test IDs.
+Keep this in-memory contract on every runner so a selector drift fails before a browser-capable
+runner is needed to prove native pointer-capture delivery.
+*/
+describe("Board card-detail Chromium locator contract", () => {
+ it("renders the production Board, Column, and TaskCard selectors exactly once", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelectorAll("main.board-workflow-columns")).toHaveLength(1);
+ expect(container.querySelectorAll(".card[data-id='FN-TITLE-FLICKER'] .card-title")).toHaveLength(1);
+ });
+
+ it("uses TaskDetailContent's embedded close control in the headerless popup", () => {
+ const { container } = render(
+
+ ({ success: true } as never)}
+ onOpenDetail={noop}
+ addToast={noop}
+ embedded
+ onRequestClose={noop}
+ />
+ ,
+ );
+
+ const popup = document.querySelectorAll(".floating-window--task-detail");
+ const close = document.querySelectorAll(".floating-window--task-detail button[aria-label='Close']");
+ expect(popup).toHaveLength(1);
+ expect(close).toHaveLength(1);
+ expect(close[0]).toHaveClass("modal-close", "task-detail-floating-close");
+ expect(container.querySelector(".floating-window__close")).toBeNull();
+ });
+});
diff --git a/packages/dashboard/app/hooks/__tests__/useBoardMousePan.test.tsx b/packages/dashboard/app/hooks/__tests__/useBoardMousePan.test.tsx
index f4c83302e0..bd977ff697 100644
--- a/packages/dashboard/app/hooks/__tests__/useBoardMousePan.test.tsx
+++ b/packages/dashboard/app/hooks/__tests__/useBoardMousePan.test.tsx
@@ -33,6 +33,7 @@ function renderPanHarness(enabled = true, onClick = vi.fn()) {
Object.defineProperties(board, {
clientWidth: { configurable: true, value: 200 },
scrollWidth: { configurable: true, value: 600 },
+ setPointerCapture: { configurable: true, value: vi.fn() },
});
return { ...result, board };
}
@@ -66,13 +67,36 @@ describe("useBoardMousePan", () => {
expect(board.scrollLeft).toBe(130);
});
+ it("keeps stationary card bodies and nested text native before panning", () => {
+ const onClick = vi.fn();
+ const { board, getByTestId } = renderPanHarness(true, onClick);
+ const setPointerCapture = vi.fn();
+ Object.defineProperty(board, "setPointerCapture", { configurable: true, value: setPointerCapture });
+
+ pointerDown(getByTestId("card"));
+ pointerUp(getByTestId("card"));
+ fireEvent.click(getByTestId("card"));
+ pointerDown(getByTestId("card-title"), 100, 50, 2);
+ pointerMove(getByTestId("card-title"), 103, 50, 2);
+ pointerUp(getByTestId("card-title"), 2);
+ fireEvent.click(getByTestId("card-title"));
+
+ expect(setPointerCapture).not.toHaveBeenCalled();
+ expect(onClick).toHaveBeenCalledTimes(2);
+ expect(board.scrollLeft).toBe(0);
+ });
+
it("pans task-card bodies and text while suppressing only the compatibility click", () => {
const onClick = vi.fn();
const { board, getByTestId } = renderPanHarness(true, onClick);
+ const setPointerCapture = vi.fn();
+ Object.defineProperty(board, "setPointerCapture", { configurable: true, value: setPointerCapture });
board.scrollLeft = 100;
pointerDown(getByTestId("card"));
+ expect(setPointerCapture).not.toHaveBeenCalled();
pointerMove(getByTestId("card"), 40);
+ expect(setPointerCapture).toHaveBeenCalledWith(1);
expect(board.scrollLeft).toBe(160);
expect(board).toHaveAttribute("data-panning", "true");
pointerUp(getByTestId("card"));
@@ -82,6 +106,7 @@ describe("useBoardMousePan", () => {
board.scrollLeft = 100;
pointerDown(getByTestId("card-title"), 100, 50, 2);
pointerMove(getByTestId("card-title"), 140, 50, 2);
+ expect(setPointerCapture).toHaveBeenCalledWith(2);
expect(board.scrollLeft).toBe(60);
pointerUp(getByTestId("card-title"), 2);
fireEvent.click(getByTestId("card-title"));
@@ -91,6 +116,34 @@ describe("useBoardMousePan", () => {
expect(onClick).toHaveBeenCalledTimes(1);
});
+ it("abandons pan arbitration when Board pointer capture is unavailable or rejected", () => {
+ const onClick = vi.fn();
+ const { board, getByTestId } = renderPanHarness(true, onClick);
+ const captureFailure = vi.fn(() => { throw new DOMException("capture rejected"); });
+ Object.defineProperty(board, "setPointerCapture", { configurable: true, value: captureFailure });
+ board.scrollLeft = 100;
+
+ pointerDown(getByTestId("card"));
+ pointerMove(getByTestId("card"), 40);
+ pointerUp(getByTestId("card"));
+ fireEvent.click(getByTestId("card"));
+
+ expect(captureFailure).toHaveBeenCalledWith(1);
+ expect(board.scrollLeft).toBe(100);
+ expect(board).toHaveAttribute("data-panning", "false");
+ expect(onClick).toHaveBeenCalledTimes(1);
+
+ Object.defineProperty(board, "setPointerCapture", { configurable: true, value: undefined });
+ pointerDown(getByTestId("card"), 100, 50, 2);
+ pointerMove(getByTestId("card"), 40, 50, 2);
+ pointerUp(getByTestId("card"), 2);
+ fireEvent.click(getByTestId("card"));
+
+ expect(board.scrollLeft).toBe(100);
+ expect(board).toHaveAttribute("data-panning", "false");
+ expect(onClick).toHaveBeenCalledTimes(2);
+ });
+
it("leaves interactive, editable, native-draggable, and editing-card surfaces native", () => {
const { board, getByTestId } = renderPanHarness();
board.scrollLeft = 100;
@@ -139,6 +192,26 @@ describe("useBoardMousePan", () => {
expect(board.scrollLeft).toBe(100);
});
+ it("fences the active candidate against mismatched pointer events", () => {
+ const { board, getByTestId } = renderPanHarness();
+ const surface = getByTestId("surface");
+ const setPointerCapture = vi.fn();
+ Object.defineProperty(board, "setPointerCapture", { configurable: true, value: setPointerCapture });
+ board.scrollLeft = 100;
+
+ pointerDown(surface, 100, 50, 1);
+ pointerDown(surface, 100, 50, 2);
+ pointerMove(surface, 40, 50, 2);
+ pointerUp(surface, 2);
+ expect(setPointerCapture).not.toHaveBeenCalled();
+ expect(board.scrollLeft).toBe(100);
+
+ pointerMove(surface, 40, 50, 1);
+ expect(setPointerCapture).toHaveBeenCalledWith(1);
+ expect(board.scrollLeft).toBe(160);
+ pointerUp(surface, 1);
+ });
+
it("does not pan before horizontal intent, without overflow, or after a stationary edgeward pointer", () => {
const { board, getByTestId } = renderPanHarness();
const surface = getByTestId("surface");
@@ -197,6 +270,8 @@ describe("useBoardMousePan", () => {
fireEvent.click(surface);
pointerDown(surface, 100, 50, 4);
+ expect(setPointerCapture).toHaveBeenCalledTimes(3);
+ pointerMove(surface, 140, 50, 4);
unmount();
expect(setPointerCapture).toHaveBeenCalledWith(4);
expect(releasePointerCapture).toHaveBeenCalledWith(4);
diff --git a/packages/dashboard/app/hooks/useBoardMousePan.ts b/packages/dashboard/app/hooks/useBoardMousePan.ts
index d24bacabbd..18c93da1d6 100644
--- a/packages/dashboard/app/hooks/useBoardMousePan.ts
+++ b/packages/dashboard/app/hooks/useBoardMousePan.ts
@@ -16,6 +16,7 @@ type BoardMousePanSession = {
startY: number;
startScrollLeft: number;
isPanning: boolean;
+ isCaptured: boolean;
};
export interface BoardMousePanBindings {
@@ -29,11 +30,11 @@ export interface BoardMousePanBindings {
}
/*
-FNXC:BoardNavigation 2026-08-21-16:03:
-FN-109 permits desktop and tablet primary-mouse panning to begin on a canonical task card's
-noninteractive body or text now that task relocation is menu-only. Controls, editable content,
-native drag sources, and editing cards remain native; mobile retains touch scrolling and column
-snap ownership, while edge proximity and stationary pointers never continue scrolling.
+FNXC:BoardNavigation 2026-08-21-18:12:
+FN-115 keeps native card activation responsible for stationary and below-threshold mouse input.
+Desktop and tablet Board capture begins only after horizontal pan intent, so card bodies/text still
+open the configured detail destination while controls, editing, native drag sources, and mobile
+column-snap ownership remain native.
*/
function isExcludedBoardPanTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) return true;
@@ -44,7 +45,20 @@ function isExcludedBoardPanTarget(target: EventTarget | null): boolean {
);
}
+function capturePointer(session: BoardMousePanSession): boolean {
+ try {
+ if (typeof session.element.setPointerCapture !== "function") return false;
+ session.element.setPointerCapture(session.pointerId);
+ session.isCaptured = true;
+ return true;
+ } catch {
+ // FNXC:BoardNavigation 2026-08-21-18:55: FN-115 returns this uncaptured candidate to native activation when the browser declines capture.
+ return false;
+ }
+}
+
function releasePointerCapture(session: BoardMousePanSession): void {
+ if (!session.isCaptured) return;
const { element, pointerId } = session;
try {
if (element.hasPointerCapture?.(pointerId)) {
@@ -81,6 +95,13 @@ export function useBoardMousePan(boardElement: HTMLElement | null, enabled: bool
return;
}
+ /*
+ FNXC:BoardNavigation 2026-08-21-21:32:
+ FN-115 fences the one active mouse candidate by pointer ID. A late or mismatched down event
+ must not replace the candidate whose eventual native click or captured pan still owns cleanup.
+ */
+ if (sessionRef.current) return;
+
didPanRef.current = false;
sessionRef.current = {
element,
@@ -89,8 +110,8 @@ export function useBoardMousePan(boardElement: HTMLElement | null, enabled: bool
startY: event.clientY,
startScrollLeft: element.scrollLeft,
isPanning: false,
+ isCaptured: false,
};
- element.setPointerCapture?.(event.pointerId);
}, [enabled]);
const onPointerMove = useCallback((event: ReactPointerEvent) => {
@@ -104,6 +125,12 @@ export function useBoardMousePan(boardElement: HTMLElement | null, enabled: bool
if (Math.abs(deltaX) < BOARD_MOUSE_PAN_THRESHOLD || Math.abs(deltaX) <= Math.abs(deltaY)) {
return;
}
+ if (!capturePointer(session)) {
+ sessionRef.current = null;
+ didPanRef.current = false;
+ setIsPanning(false);
+ return;
+ }
session.isPanning = true;
didPanRef.current = true;
setIsPanning(true);
diff --git a/packages/dashboard/app/task-modal-touch-resize-e2e-fixture.tsx b/packages/dashboard/app/task-modal-touch-resize-e2e-fixture.tsx
index 1111d6f12b..09337794fb 100644
--- a/packages/dashboard/app/task-modal-touch-resize-e2e-fixture.tsx
+++ b/packages/dashboard/app/task-modal-touch-resize-e2e-fixture.tsx
@@ -22,6 +22,7 @@ import { ConfirmDialogProvider } from "./hooks/useConfirm";
const params = new URLSearchParams(window.location.search);
const surface = params.get("surface") ?? "new-task";
const titleMode = params.get("titleMode") ?? "overflow";
+const boardCardClickSurface = surface === "board-card-click-app";
if (params.has("reset")) localStorage.clear();
/*
@@ -58,7 +59,18 @@ window.fetch = async (input) => {
const payload = url.includes("/projects/across-nodes")
? [{ id: "fixture", name: "Fixture", path: "/fixture", status: "active" }]
: url.includes("/tasks/board-workflows")
- ? { flagEnabled: true, defaultWorkflowId: "fixture-workflow", taskWorkflowIds: { [fixtureTask.id]: "fixture-workflow" }, workflows: [{ id: "fixture-workflow", name: "Fixture", columns: [{ id: "todo", name: "Todo", flags: {} }] }] }
+ ? { flagEnabled: true, defaultWorkflowId: "fixture-workflow", taskWorkflowIds: { [fixtureTask.id]: "fixture-workflow" }, workflows: [{ id: "fixture-workflow", name: "Fixture", columns: boardCardClickSurface ? [
+ /*
+ FNXC:BoardNavigation 2026-08-21-18:57:
+ FN-115's production-App Chromium fixture needs measured horizontal overflow at desktop and
+ tablet widths, so it supplies enough canonical workflow columns to exercise Board panning.
+ */
+ { id: "todo", name: "Todo", flags: {} },
+ { id: "in-progress", name: "In progress", flags: {} },
+ { id: "in-review", name: "In review", flags: {} },
+ { id: "verify", name: "Verify", flags: {} },
+ { id: "done", name: "Done", flags: {} },
+ ] : [{ id: "todo", name: "Todo", flags: {} }, { id: "in-progress", name: "In progress", flags: {} }, { id: "in-review", name: "In review", flags: {} }] }] }
: url.includes(`/tasks/${fixtureTask.id}/prompt`)
? { id: fixtureTask.id, prompt: "" }
: pathname === `/api/tasks/${fixtureTask.id}`
@@ -75,7 +87,7 @@ window.fetch = async (input) => {
? { goals: [] }
: url.includes("/models")
? { models: [], favoriteProviders: [], favoriteModels: [] }
- : url.includes("/settings") ? { taskPopupsBoardListOnly: false }
+ : url.includes("/settings") ? { taskPopupsBoardListOnly: false, openMobileTasksInPopup: params.get("openMobileTasksInPopup") === "true" }
: url.includes("/agents") || url.includes("/nodes") ? []
: [];
return new Response(JSON.stringify(payload), { headers: { "content-type": "application/json" } });
@@ -108,7 +120,7 @@ The App pop-out browser route hydrates the same project and task caches used aft
session, so its board card exists on App's first render. This removes timing retries from the
fixture while App still revalidates the stable mocked API data through its production hooks.
*/
-if (surface === "task-detail-title-app-floating") {
+if (surface === "task-detail-title-app-floating" || surface === "board-card-click-app") {
const savedAt = Date.now();
localStorage.setItem("kb-dashboard-projects-cache", JSON.stringify({ savedAt, data: [{ id: "fixture", name: "Fixture", path: "/fixture", status: "active" }] }));
localStorage.setItem("kb-dashboard-current-project-cache", JSON.stringify({ savedAt, data: "fixture" }));
@@ -281,7 +293,7 @@ function GenericFloatingWindowHarness() {
function Fixture() {
return
- {surface === "task-detail-title-app-floating" ? : surface === "agent-list-modal" ? undefined} addToast={() => undefined} /> : surface === "setup-wizard-modal" ? undefined} onClose={() => undefined} /> : surface === "floating-window" ? : surface === "floating-window-headerless" ? : surface === "floating-window-generic" ? : surface === "task-detail-title-modal" ? : surface === "task-detail-title-main-panel" ? : surface === "task-detail-title-list" ? : surface === "task-detail-title-dock" ? : surface === "task-detail-title-floating" ? : surface === "task-detail-title-embedded" ? : surface === "task-detail" ? : : surface === "agent-list-modal" ? undefined} addToast={() => undefined} /> : surface === "setup-wizard-modal" ? undefined} onClose={() => undefined} /> : surface === "floating-window" ? : surface === "floating-window-headerless" ? : surface === "floating-window-generic" ? : surface === "task-detail-title-modal" ? : surface === "task-detail-title-main-panel" ? : surface === "task-detail-title-list" ? : surface === "task-detail-title-dock" ? : surface === "task-detail-title-floating" ? : surface === "task-detail-title-embedded" ? : surface === "task-detail" ? : undefined}
diff --git a/packages/dashboard/src/__tests__/task-modal-touch-resize-browser.test.ts b/packages/dashboard/src/__tests__/task-modal-touch-resize-browser.test.ts
index f376150d41..5564ecded3 100644
--- a/packages/dashboard/src/__tests__/task-modal-touch-resize-browser.test.ts
+++ b/packages/dashboard/src/__tests__/task-modal-touch-resize-browser.test.ts
@@ -8,7 +8,7 @@ import path from "node:path";
const requireFromEngine = createRequire(new URL("../../../engine/package.json", import.meta.url));
const { chromium } = requireFromEngine("playwright-core") as { chromium: { launch(options: { executablePath: string; headless: boolean; args?: string[] }): Promise } };
type Browser = { newPage(options: { viewport: { width: number; height: number } }): Promise; close(): Promise };
-type Page = { goto(url: string): Promise; evaluate(fn: (arg: Arg) => T, arg?: Arg): Promise; locator(selector: string): Locator; waitForTimeout(ms: number): Promise; screenshot(options: { path: string }): Promise; close(): Promise; context(): { newCDPSession(page: Page): Promise }; on(event: "console" | "pageerror", listener: (message: { text?(): string; message?: string }) => void): void };
+type Page = { goto(url: string): Promise; evaluate(fn: (arg: Arg) => T, arg?: Arg): Promise; locator(selector: string): Locator; mouse: { move(x: number, y: number): Promise; down(): Promise; up(): Promise }; waitForTimeout(ms: number): Promise; screenshot(options: { path: string }): Promise; close(): Promise; context(): { newCDPSession(page: Page): Promise }; on(event: "console" | "pageerror", listener: (message: { text?(): string; message?: string }) => void): void };
type Locator = { boundingBox(): Promise<{ x: number; y: number; width: number; height: number } | null> };
type Cdp = { send(method: string, params: Record): Promise };
type Point = { x: number; y: number };
@@ -41,6 +41,7 @@ const screenshots = path.resolve(process.cwd(), "e2e/__screenshots__/fn-8602");
const floatingWindowScreenshots = path.resolve(process.cwd(), "e2e/__screenshots__/fn-8605");
const fn8607Screenshots = path.resolve(process.cwd(), "e2e/__screenshots__/fn-8607");
const fn8806Screenshots = path.resolve(process.cwd(), "e2e/__screenshots__/fn-8806");
+const fn115Screenshots = path.resolve(process.cwd(), "e2e/__screenshots__/fn-115");
async function touchDrag(cdp: Cdp, point: Point, delta = { x: 48, y: 36 }) {
await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: point.x, y: point.y, id: 1 }] });
@@ -779,6 +780,78 @@ describe.runIf(executablePath)("Task modal tablet touch resize browser regressio
await page.close();
}, 30_000);
+ /*
+ FNXC:BoardNavigation 2026-08-21-18:12:
+ FN-115 requires native Chromium mouse delivery because jsdom cannot model pointer-capture
+ retargeting. A stationary card click must open the production popup; a horizontal card drag must
+ pan without opening it, and the next click must remain usable on desktop, tablet, and mobile touch.
+ */
+ for (const [name, width, height] of [["desktop", 1280, 900], ["tablet", 820, 1180]] as const) {
+ it(`opens a Board task popup after a stationary card click at ${name} width`, async () => {
+ const page = await browser.newPage({ viewport: { width, height } });
+ const cdp = await page.context().newCDPSession(page);
+ await setDesktopMetrics(cdp, width, height);
+ await page.goto(`${baseUrl}app/task-modal-touch-resize-e2e-fixture.html?surface=board-card-click-app&openMobileTasksInPopup=true&reset=1`);
+ await page.waitForTimeout(350);
+ const cardSelector = ".card[data-id='FN-TITLE-FLICKER'] .card-title";
+ const boardSelector = "main.board-workflow-columns";
+ const ready = await page.evaluate(async ({ cardSelector, boardSelector }) => {
+ for (let frame = 0; frame < 12; frame++) {
+ const card = document.querySelector(cardSelector);
+ const board = document.querySelector(boardSelector);
+ if (card && board && board.scrollWidth > board.clientWidth) return true;
+ await new Promise((resolve) => requestAnimationFrame(() => resolve()));
+ }
+ return false;
+ }, { cardSelector, boardSelector });
+ expect(ready).toBe(true);
+ const card = await targetCenter(page, cardSelector);
+ await page.mouse.move(card.x, card.y);
+ await page.mouse.down();
+ await page.mouse.up();
+ await page.waitForTimeout(100);
+ expect(await page.evaluate(() => Boolean(document.querySelector(".floating-window--task-detail")))).toBe(true);
+ if (name === "desktop") {
+ await mkdir(fn115Screenshots, { recursive: true });
+ await page.screenshot({ path: path.join(fn115Screenshots, "task-card-detail-open.png") });
+ }
+ const close = await targetCenter(page, ".floating-window--task-detail button[aria-label='Close']");
+ await page.mouse.move(close.x, close.y);
+ await page.mouse.down();
+ await page.mouse.up();
+ await page.waitForTimeout(100);
+ expect(await page.evaluate(() => document.querySelector(".floating-window--task-detail") === null)).toBe(true);
+ const beforeDrag = await page.evaluate((selector) => document.querySelector(selector)?.scrollLeft ?? 0, boardSelector);
+ await page.mouse.move(card.x, card.y);
+ await page.mouse.down();
+ await page.mouse.move(card.x - 48, card.y);
+ await page.mouse.up();
+ await page.waitForTimeout(100);
+ expect(await page.evaluate((selector) => document.querySelector(selector)?.scrollLeft ?? 0, boardSelector)).not.toBe(beforeDrag);
+ expect(await page.evaluate(() => Boolean(document.querySelector(".floating-window--task-detail")))).toBe(false);
+ await page.mouse.move(card.x, card.y);
+ await page.mouse.down();
+ await page.mouse.up();
+ await page.waitForTimeout(100);
+ expect(await page.evaluate(() => Boolean(document.querySelector(".floating-window--task-detail")))).toBe(true);
+ await page.close();
+ }, 30_000);
+ }
+
+ it("keeps mobile task-card touch activation outside the desktop pan owner", async () => {
+ const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
+ const cdp = await page.context().newCDPSession(page);
+ await cdp.send("Emulation.setDeviceMetricsOverride", { width: 390, height: 844, screenWidth: 390, screenHeight: 844, deviceScaleFactor: 1, mobile: true });
+ await cdp.send("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 });
+ await page.goto(`${baseUrl}app/task-modal-touch-resize-e2e-fixture.html?surface=board-card-click-app&openMobileTasksInPopup=true&reset=1`);
+ await page.waitForTimeout(350);
+ const card = await targetCenter(page, ".card[data-id='FN-TITLE-FLICKER'] .card-title");
+ await touchTap(cdp, card);
+ await page.waitForTimeout(100);
+ expect(await page.evaluate(() => Boolean(document.querySelector(".floating-window--task-detail")))).toBe(true);
+ await page.close();
+ }, 30_000);
+
it("keeps the true-phone sheet free of active resize targets", async () => {
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
const cdp = await page.context().newCDPSession(page);