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:
7
.changeset/fn-115-restore-board-card-click.md
Normal file
7
.changeset/fn-115-restore-board-card-click.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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" : ""}`;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user