FN-115: restore task-card detail clicks

Restore native task-card activation while preserving intentional board panning.

- Start board panning only after horizontal intent is established.
- Preserve stationary card clicks and add regression coverage for detail locators and touch resizing.
- Document the restored interaction behavior and publish a patch changeset.

Files changed:
 .changeset/fn-115-restore-board-card-click.md      |   7 ++
 docs/dashboard-guide.md                            |   8 +-
 packages/dashboard/app/components/Board.tsx        |   9 +-
 .../app/components/__tests__/Board.test.tsx        |  47 ++++++---
 .../__tests__/board-card-detail-locators.test.tsx  | 108 +++++++++++++++++++++
 .../app/hooks/__tests__/useBoardMousePan.test.tsx  |  75 ++++++++++++++
 packages/dashboard/app/hooks/useBoardMousePan.ts   |  39 ++++++--
 .../app/task-modal-touch-resize-e2e-fixture.tsx    |  20 +++-
 .../task-modal-touch-resize-browser.test.ts        |  75 +++++++++++++-
 9 files changed, 354 insertions(+), 34 deletions(-)

Fusion-Task-Id: FN-115

Fusion-Task-Lineage: 158de270-2a5c-4590-8347-a3c4d6a5db03

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-21 23:14:55 +00:00
parent 10c399d01e
commit 35f0247028
9 changed files with 354 additions and 34 deletions

View File

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

View File

@@ -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
<!-- FNXC:BoardNavigationDocs 2026-08-21-16:09: FN-109 documents that desktop/tablet card-body panning moves only the Board viewport; controls, editing, contextual movement, and phone touch ownership remain independent. -->
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.
<!-- FNXC:BoardNavigationDocs 2026-08-21-18:21: FN-115 preserves native task-card activation until horizontal Board pan intent is proven, so stationary clicks continue to reach the configured popup, right-dock, or main-panel detail destination. -->
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.
<!-- FNXC:BoardNavigationDocs 2026-08-18-19:10: Phone releases keep free finger scrolling, then use one smooth controlled normal-motion settle to the already-valid reachable column. Reduced-motion users still receive an immediate landing, while programmatic, refresh, resize, and restored-page scrolls remain unsnapped. -->
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:
<!-- FNXC:TaskCardMovement 2026-08-19-18:52: Board and List task relocation is intentionally menu-only, so native card dragging cannot bypass the same explicit Move to choice on desktop, keyboard, and touch surfaces. -->
- 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.
<!-- FNXC:BoardNavigation 2026-08-20-04:52: Desktop and tablet Board panning restores safe noninteractive descendants, including empty-column text, while cards and controls remain native. Edge proximity never starts or continues scrolling, and phones retain their separate native touch and column-snap ownership. -->
- 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.
<!-- FNXC:BoardNavigation 2026-08-21-21:17: FN-115 keeps ordinary task-card clicks native until horizontal intent is proven, while preserving FN-109 card-body panning after the threshold. Edge proximity never starts or continues scrolling, and phones retain their separate native touch and column-snap ownership. -->
- 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

View File

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

View File

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

View File

@@ -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(
<Board
tasks={[task]}
maxConcurrent={2}
onMoveTask={noopAsync}
onOpenDetail={noop}
addToast={noop}
onQuickCreate={noopAsync}
onNewTask={noop}
autoMerge
onToggleAutoMerge={noop}
planAutoApproveEnabled={false}
onTogglePlanAutoApprove={noop}
globalPaused={false}
/>,
);
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(
<FloatingWindow
windowKey="fn-115-locator-contract"
title="Task detail"
onClose={noop}
hideHeader
className="floating-window--task-detail"
>
<TaskDetailContent
task={task}
onMoveTask={noopAsync}
onDeleteTask={noopAsync}
onMergeTask={async () => ({ success: true } as never)}
onOpenDetail={noop}
addToast={noop}
embedded
onRequestClose={noop}
/>
</FloatingWindow>,
);
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();
});
});

View File

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

View File

@@ -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<HTMLElement>) => {
@@ -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);

View File

@@ -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 <I18nextProvider i18n={i18n}>
<ConfirmDialogProvider skipConfirmations>
{surface === "task-detail-title-app-floating" ? <TaskDetailTitleAppFloatingHarness /> : surface === "agent-list-modal" ? <AgentListModal isOpen onClose={() => undefined} addToast={() => undefined} /> : surface === "setup-wizard-modal" ? <SetupWizardModal onProjectRegistered={() => undefined} onClose={() => undefined} /> : surface === "floating-window" ? <FloatingWindowHarness /> : surface === "floating-window-headerless" ? <HeaderlessFloatingWindowHarness /> : surface === "floating-window-generic" ? <GenericFloatingWindowHarness /> : surface === "task-detail-title-modal" ? <TaskDetailTitleModalHarness /> : surface === "task-detail-title-main-panel" ? <TaskDetailTitleMainPanelHarness /> : surface === "task-detail-title-list" ? <TaskDetailTitleListHarness /> : surface === "task-detail-title-dock" ? <TaskDetailTitleDockHarness /> : surface === "task-detail-title-floating" ? <TaskDetailTitleFloatingHarness /> : surface === "task-detail-title-embedded" ? <TaskDetailTitleEmbeddedHarness /> : surface === "task-detail" ? <TaskDetailResizeHarness /> : <NewTaskModal
{surface === "task-detail-title-app-floating" || surface === "board-card-click-app" ? <TaskDetailTitleAppFloatingHarness /> : surface === "agent-list-modal" ? <AgentListModal isOpen onClose={() => undefined} addToast={() => undefined} /> : surface === "setup-wizard-modal" ? <SetupWizardModal onProjectRegistered={() => undefined} onClose={() => undefined} /> : surface === "floating-window" ? <FloatingWindowHarness /> : surface === "floating-window-headerless" ? <HeaderlessFloatingWindowHarness /> : surface === "floating-window-generic" ? <GenericFloatingWindowHarness /> : surface === "task-detail-title-modal" ? <TaskDetailTitleModalHarness /> : surface === "task-detail-title-main-panel" ? <TaskDetailTitleMainPanelHarness /> : surface === "task-detail-title-list" ? <TaskDetailTitleListHarness /> : surface === "task-detail-title-dock" ? <TaskDetailTitleDockHarness /> : surface === "task-detail-title-floating" ? <TaskDetailTitleFloatingHarness /> : surface === "task-detail-title-embedded" ? <TaskDetailTitleEmbeddedHarness /> : surface === "task-detail" ? <TaskDetailResizeHarness /> : <NewTaskModal
isOpen
tasks={[]}
onClose={() => undefined}

View File

@@ -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<Browser> } };
type Browser = { newPage(options: { viewport: { width: number; height: number } }): Promise<Page>; close(): Promise<void> };
type Page = { goto(url: string): Promise<unknown>; evaluate<T, Arg = undefined>(fn: (arg: Arg) => T, arg?: Arg): Promise<T>; locator(selector: string): Locator; waitForTimeout(ms: number): Promise<void>; screenshot(options: { path: string }): Promise<void>; close(): Promise<void>; context(): { newCDPSession(page: Page): Promise<Cdp> }; on(event: "console" | "pageerror", listener: (message: { text?(): string; message?: string }) => void): void };
type Page = { goto(url: string): Promise<unknown>; evaluate<T, Arg = undefined>(fn: (arg: Arg) => T, arg?: Arg): Promise<T>; locator(selector: string): Locator; mouse: { move(x: number, y: number): Promise<void>; down(): Promise<void>; up(): Promise<void> }; waitForTimeout(ms: number): Promise<void>; screenshot(options: { path: string }): Promise<void>; close(): Promise<void>; context(): { newCDPSession(page: Page): Promise<Cdp> }; 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<string, unknown>): Promise<unknown> };
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<HTMLElement>(cardSelector);
const board = document.querySelector<HTMLElement>(boardSelector);
if (card && board && board.scrollWidth > board.clientWidth) return true;
await new Promise<void>((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<HTMLElement>(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<HTMLElement>(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);