diff --git a/.changeset/fn-109-board-card-pan.md b/.changeset/fn-109-board-card-pan.md new file mode 100644 index 0000000000..f2ddc74068 --- /dev/null +++ b/.changeset/fn-109-board-card-pan.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Pan desktop and tablet Boards from noninteractive task card bodies. +category: fix +dev: Keeps task relocation in the contextual Move to menu. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 73c2673b8a..cda7e6fc30 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -161,7 +161,10 @@ When task detail is open from a board card, task popup, mobile list row, right-d On mobile board-card detail, **Back to board** also restores the prior board/card scroll position so the same lane context remains visible. -### Mobile Kanban column snapping +### 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 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. diff --git a/docs/task-management.md b/docs/task-management.md index d0c5ac0aef..ab8938aa73 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -362,12 +362,14 @@ Board ordering behavior: - The `done` column is recency-ordered by completion time (newest first), using `columnMovedAt` as primary and falling back to `updatedAt` then `createdAt` for legacy tasks. - The dashboard **list view default ordering matches these same per-column semantics** until a user clicks a sortable header (manual list sorting still overrides defaults). - + ### Moving tasks on Board and List Use a task card or List row's context menu (**right-click**, **Shift+F10** / Context Menu key, the visible overflow control, or touch long-press), then choose **Move to**. When more than one legal destination is available, **Move to** opens one submenu containing each destination; a single destination remains a direct action. This changes task movement only. +On desktop and tablet Boards, dragging a task card's noninteractive body or text pans the Board viewport without moving the task. Controls and editing remain native; **Move to** remains the only task-relocation path. + ### Lifecycle commands ```bash diff --git a/packages/dashboard/app/components/Board.css b/packages/dashboard/app/components/Board.css index 4ddca5810a..55967b9901 100644 --- a/packages/dashboard/app/components/Board.css +++ b/packages/dashboard/app/components/Board.css @@ -45,9 +45,10 @@ The board needs a neutral first-paint shell whenever workflow lanes are enabled } /* -FNXC:BoardNavigation 2026-08-20-04:47: -Feedback begins only after an eligible desktop/tablet safe-surface drag crosses horizontal intent. -It never activates for excluded cards or controls, the unbound skeleton, or the disabled mobile path. +FNXC:BoardNavigation 2026-08-21-16:03: +FN-109 shows grab feedback only after a desktop/tablet pan crosses horizontal intent from a safe +surface or noninteractive task-card body/text. Controls, editing cards, the unbound skeleton, and +the disabled mobile path never activate it; this gesture pans the viewport and never relocates a task. */ .board.board-workflow-columns.is-mouse-panning { cursor: grabbing; diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 25f5453ad0..8a7e391f5c 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -231,10 +231,10 @@ export function Board({ tasks, projectId, maxConcurrent, effectiveMaxConcurrent const mobileFullTaskModalHidden = viewportMode === "mobile"; useColumnScrollSnap(boardElement, { mobileOnly: true }); /* - FNXC:BoardNavigation 2026-08-20-04:47: - Selected and All-workflows live roots share safe-surface primary-mouse panning outside mobile; - empty-column text may pan, while cards and controls remain native. The skeleton stays unbound, - and the separate mobile-only snap owner plus mobile styles remain unchanged. + 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. */ 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 a10ffe662c..abe0450771 100644 --- a/packages/dashboard/app/components/__tests__/Board.test.tsx +++ b/packages/dashboard/app/components/__tests__/Board.test.tsx @@ -76,6 +76,8 @@ vi.mock("../Column", () => ({ canDropTask, onPlanningMode, taskWorkflowBadges, + onOpenDetail, + onMoveTask, }: { column: string; tasks: Task[]; @@ -101,6 +103,8 @@ vi.mock("../Column", () => ({ canDropTask?: unknown; onPlanningMode?: unknown; taskWorkflowBadges?: ReadonlyMap; + onOpenDetail?: (task: Task) => void; + onMoveTask?: (id: string, column: string) => Promise; }) => { columnRenderCounts[column] = (columnRenderCounts[column] ?? 0) + 1; return ( @@ -117,8 +121,10 @@ vi.mock("../Column", () => ({ ) : null} {tasks.length === 0 ?
No tasks
: null} {tasks.map((task) => ( -
- {task.title ?? task.description ?? task.id} +
onOpenDetail?.(task)}> + {task.title ?? task.description ?? task.id} + +
))} {onToggleCollapse && } @@ -2037,38 +2043,82 @@ describe("Board", () => { expect(board.scrollLeft).toBe(scrollAfterPointerMove); }); - it("keeps touch and task-card interactions native", async () => { + it("pans from task-card bodies in selected and All-workflows Boards without moving or opening tasks", async () => { const onQuickCreate = vi.fn().mockResolvedValue({}); - enableFlag({ "FN-1": "builtin:coding" }); - renderBoard({ tasks: [mkTask({ id: "FN-1" })], onQuickCreate }); + const onOpenDetail = vi.fn(); + const onMoveTask = vi.fn().mockResolvedValue({}); + enableFlag({ "FN-1": "builtin:coding", "FN-2": "wf-custom" }, [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW]); + renderBoard({ tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-2", column: "intake" })], onQuickCreate, onOpenDetail, onMoveTask }); - const board = screen.getByRole("main") as HTMLElement; - makeBoardHorizontallyScrollable(board); - fireEvent.pointerDown(board, { button: 0, clientX: 100, clientY: 50, pointerId: 1, pointerType: "touch" }); - fireEvent.pointerMove(board, { clientX: 40, clientY: 50, pointerId: 1, pointerType: "touch" }); - fireEvent.pointerUp(board, { pointerId: 1, pointerType: "touch" }); - expect(board.scrollLeft).toBe(100); - expect(board).toHaveClass("board", "board-workflow-columns"); - - const card = screen.getByTestId("board-task-card-FN-1"); + const selectedBoard = screen.getByRole("main") as HTMLElement; + makeBoardHorizontallyScrollable(selectedBoard); + const selectedCard = screen.getByTestId("board-task-card-FN-1"); /* - FNXC:TaskCardInteraction 2026-08-19-19:07: - Native task dragging is retired; cards remain ordinary pointer targets so board panning excludes them without preserving the drag contract. + 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. */ - expect(card).not.toHaveAttribute("draggable"); - fireEvent.pointerDown(card, { button: 0, clientX: 100, clientY: 50, pointerId: 2, pointerType: "mouse" }); - fireEvent.pointerMove(card, { clientX: 40, clientY: 50, pointerId: 2, pointerType: "mouse" }); - fireEvent.pointerUp(card, { pointerId: 2, pointerType: "mouse" }); - expect(board.scrollLeft).toBe(100); + expect(selectedCard).not.toHaveAttribute("draggable"); + fireEvent.pointerDown(selectedCard, { button: 0, clientX: 100, clientY: 50, pointerId: 1, pointerType: "mouse" }); + fireEvent.pointerMove(selectedCard, { clientX: 40, clientY: 50, pointerId: 1, pointerType: "mouse" }); + expect(selectedBoard.scrollLeft).toBe(160); + expect(selectedBoard).toHaveClass("is-mouse-panning"); + fireEvent.pointerUp(selectedCard, { pointerId: 1, pointerType: "mouse" }); + expect(selectedBoard).not.toHaveClass("is-mouse-panning"); + fireEvent.click(selectedCard); + expect(onOpenDetail).not.toHaveBeenCalled(); + expect(onMoveTask).not.toHaveBeenCalled(); + expect(screen.getByTestId("column-todo")).toHaveAttribute("data-tasks", expect.stringContaining("FN-1")); + + fireEvent.click(selectedCard); + expect(onOpenDetail).toHaveBeenCalledTimes(1); + 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" }); + fireEvent.pointerMove(selectedControl, { clientX: 40, clientY: 50, pointerId: 2, pointerType: "mouse" }); + fireEvent.pointerUp(selectedControl, { pointerId: 2, pointerType: "mouse" }); + fireEvent.click(selectedControl); + expect(selectedBoard.scrollLeft).toBe(100); + expect(onOpenDetail).toHaveBeenCalledTimes(1); + + 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" }); + expect(aggregateBoard).not.toHaveClass("is-mouse-panning"); + fireEvent.click(aggregateTitle); + expect(onOpenDetail).toHaveBeenCalledTimes(1); + expect(onMoveTask).not.toHaveBeenCalled(); + + fireEvent.click(aggregateTitle); + expect(onOpenDetail).toHaveBeenCalledTimes(2); const quickCreate = screen.getByTestId("mock-quick-create-triage"); - fireEvent.pointerDown(quickCreate, { button: 0, clientX: 100, clientY: 50, pointerId: 3, pointerType: "mouse" }); - fireEvent.pointerMove(quickCreate, { clientX: 40, clientY: 50, pointerId: 3, pointerType: "mouse" }); - fireEvent.pointerUp(quickCreate, { pointerId: 3, pointerType: "mouse" }); + 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.click(quickCreate); expect(onQuickCreate).toHaveBeenCalledTimes(1); }); + it("keeps touch input outside the desktop card-origin pan owner", () => { + enableFlag({ "FN-1": "builtin:coding" }); + renderBoard({ tasks: [mkTask({ id: "FN-1" })] }); + const board = screen.getByRole("main") as HTMLElement; + makeBoardHorizontallyScrollable(board); + const card = screen.getByTestId("board-task-card-FN-1"); + + fireEvent.pointerDown(card, { button: 0, clientX: 100, clientY: 50, pointerId: 1, pointerType: "touch" }); + fireEvent.pointerMove(card, { clientX: 40, clientY: 50, pointerId: 1, pointerType: "touch" }); + fireEvent.pointerUp(card, { pointerId: 1, pointerType: "touch" }); + expect(board.scrollLeft).toBe(100); + expect(board).toHaveClass("board", "board-workflow-columns"); + }); + it("disables desktop mouse panning at the mobile viewport without changing touch ownership", () => { const harness = installMobileBoardStabilizationHarness(); try { diff --git a/packages/dashboard/app/hooks/__tests__/useBoardMousePan.test.tsx b/packages/dashboard/app/hooks/__tests__/useBoardMousePan.test.tsx index ff44d7f842..f4c83302e0 100644 --- a/packages/dashboard/app/hooks/__tests__/useBoardMousePan.test.tsx +++ b/packages/dashboard/app/hooks/__tests__/useBoardMousePan.test.tsx @@ -14,7 +14,15 @@ function PanHarness({ enabled = true, onClick = vi.fn() }: { enabled?: boolean;
Editable content
Draggable
-
Card
+
+ Card + + Card link + +
Card editable content
+
Card draggable
+
Card semantic control
+
); } @@ -58,16 +66,49 @@ describe("useBoardMousePan", () => { expect(board.scrollLeft).toBe(130); }); - it("leaves excluded interactive, editable, native-draggable, and task surfaces native", () => { + it("pans task-card bodies and text while suppressing only the compatibility click", () => { + const onClick = vi.fn(); + const { board, getByTestId } = renderPanHarness(true, onClick); + board.scrollLeft = 100; + + pointerDown(getByTestId("card")); + pointerMove(getByTestId("card"), 40); + expect(board.scrollLeft).toBe(160); + expect(board).toHaveAttribute("data-panning", "true"); + pointerUp(getByTestId("card")); + fireEvent.click(getByTestId("card")); + expect(onClick).not.toHaveBeenCalled(); + + board.scrollLeft = 100; + pointerDown(getByTestId("card-title"), 100, 50, 2); + pointerMove(getByTestId("card-title"), 140, 50, 2); + expect(board.scrollLeft).toBe(60); + pointerUp(getByTestId("card-title"), 2); + fireEvent.click(getByTestId("card-title")); + expect(onClick).not.toHaveBeenCalled(); + + fireEvent.click(getByTestId("card-title")); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("leaves interactive, editable, native-draggable, and editing-card surfaces native", () => { const { board, getByTestId } = renderPanHarness(); board.scrollLeft = 100; - for (const [index, target] of ["button", "input", "contenteditable", "draggable", "card"].map(getByTestId).entries()) { + for (const [index, target] of [ + "button", "input", "contenteditable", "draggable", "card-button", "card-link", "card-input", + "card-contenteditable", "card-draggable", "card-semantic-control", + ].map(getByTestId).entries()) { pointerDown(target, 100, 50, index + 1); pointerMove(target, 40, 50, index + 1); pointerUp(target, index + 1); } + getByTestId("card").classList.add("card-editing"); + pointerDown(getByTestId("card"), 100, 50, 20); + pointerMove(getByTestId("card"), 40, 50, 20); + pointerUp(getByTestId("card"), 20); + expect(board.scrollLeft).toBe(100); expect(board).toHaveAttribute("data-panning", "false"); }); diff --git a/packages/dashboard/app/hooks/useBoardMousePan.ts b/packages/dashboard/app/hooks/useBoardMousePan.ts index e015b66907..d24bacabbd 100644 --- a/packages/dashboard/app/hooks/useBoardMousePan.ts +++ b/packages/dashboard/app/hooks/useBoardMousePan.ts @@ -29,17 +29,17 @@ export interface BoardMousePanBindings { } /* -FNXC:BoardNavigation 2026-08-20-04:47: -Desktop and tablet Board navigation restores primary-mouse click-drag panning from safe, -noninteractive descendants such as empty-column text. Interactive, editable, native-draggable, -and task-card descendants keep their native behavior. Mobile owns touch scrolling and column snap, -so this hook is disabled there; edge proximity and stationary pointers never continue scrolling. +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. */ function isExcludedBoardPanTarget(target: EventTarget | null): boolean { if (!(target instanceof Element)) return true; return Boolean( target.closest( - "button, a, input, textarea, select, option, label, summary, [contenteditable='true'], [draggable='true'], [data-id], [role='button'], [role='link'], [role='textbox'], [role='menuitem'], [role='checkbox'], [role='combobox'], [role='radio'], [role='slider'], [role='switch']", + ".card-editing, button, a, input, textarea, select, option, label, summary, [contenteditable='true'], [draggable='true'], [role='button'], [role='link'], [role='textbox'], [role='menuitem'], [role='checkbox'], [role='combobox'], [role='radio'], [role='slider'], [role='switch']", ), ); }