FN-194: prevent involuntary Kanban scroll from text selection

Prevent native text-selection autoscroll from competing with intentional Board panning while preserving editable controls.

- Suppress selection across Board surfaces and restore it for editable descendants.
- Document the scrolling behavior and add CSS and component coverage for responsive and grouped columns.
- Add a patch changeset for the dashboard behavior fix.

Files changed:
 .changeset/fn-194-board-text-selection.md          |   7 +
 docs/dashboard-guide.md                            |   5 +-
 .../app/__tests__/board-text-selection.test.ts     |  86 ++++++++++++
 packages/dashboard/app/components/Board.css        |  20 ++-
 packages/dashboard/app/components/Board.tsx        |   5 +
 .../__tests__/Board.text-selection.test.tsx        | 144 +++++++++++++++++++++
 6 files changed, 264 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-194

Fusion-Task-Lineage: 65f46bed-76f1-4e5f-9dce-7e6ce26b5fa5

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-27 10:21:13 +00:00
parent e213b94f25
commit 1936c78774
6 changed files with 264 additions and 3 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Prevent board text selection from involuntarily scrolling Kanban columns.
category: fix
dev: Adds a board-wide selection suppression rule with editable-content opt-ins.

View File

@@ -186,7 +186,8 @@ On mobile board-card detail, **Back to board** also restores the prior board/car
Reverted work remains in its own workflow column and is marked with a **Reverted** label; it is not collected into a separate board, list, or right-dock group. Its resolution actions remain available on the Board and right-dock card, in the List row context menu, and in Task Detail: use **Delete** to remove the task or **Revise** to create a follow-up revision.
<!-- 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-27-10:09: FN-194 intentionally makes Board text non-selectable because native selection-autoscroll was a second, involuntary horizontal scroll path before the mouse-pan threshold. Editable Board controls retain native selection. -->
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. Board text is intentionally not selectable, so mouse drag pan and native wheel, trackpad, scrollbar, and keyboard scrolling are the only horizontal-scroll systems. 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. Quick-create, inline card editing, and dependency search keep normal text selection. 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.
@@ -354,7 +355,7 @@ 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-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.
- 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. Board text is intentionally non-selectable, preventing native text-selection autoscroll from competing with the pan; editable quick-create, inline card editing, and dependency search retain normal selection. 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

@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { loadAllAppCss, loadAllAppCssBaseOnly, loadComponentCss } from "../test/cssFixture";
type CssRule = { selector: string; declarations: string };
function findRules(css: string, selectorFragment: string): CssRule[] {
const rules: CssRule[] = [];
const pattern = /([^{}]+)\{([\s\S]*?)\}/g;
for (const match of css.matchAll(pattern)) {
const [, selector, declarations] = match;
if (selector.includes(selectorFragment)) {
rules.push({ selector, declarations });
}
}
return rules;
}
function expectRuleToContain(css: string, selectorFragment: string, declaration: string): CssRule {
const rules = findRules(css, selectorFragment);
expect(rules).not.toHaveLength(0);
const rule = rules.find(({ declarations }) => declarations.includes(declaration));
expect(rule).toBeDefined();
return rule!;
}
function mediaBlocks(css: string): string[] {
const blocks: string[] = [];
const mediaStart = /@media[^\{]+\{/g;
for (const match of css.matchAll(mediaStart)) {
const start = match.index! + match[0].length;
let depth = 1;
let end = start;
while (end < css.length && depth > 0) {
if (css[end] === "{") depth++;
if (css[end] === "}") depth--;
end++;
}
blocks.push(css.slice(start, end - 1));
}
return blocks;
}
describe("board text-selection CSS contract (FN-194)", () => {
it("suppresses selection from every base board descendant while retaining editable opt-ins", () => {
const baseCss = loadAllAppCssBaseOnly();
const suppressionRule = expectRuleToContain(baseCss, ".board *", "user-select: none;");
expect(suppressionRule.declarations).toContain("-webkit-user-select: none;");
const optInRule = expectRuleToContain(baseCss, ".board :is(", "user-select: text;");
expect(optInRule.declarations).toContain("-webkit-user-select: text;");
expect(optInRule.selector).toContain("input");
expect(optInRule.selector).toContain("textarea");
expect(optInRule.selector).toContain("select");
expect(optInRule.selector).toContain('[contenteditable="true"]');
expect(optInRule.selector).toContain(".card-editing");
});
it("does not re-enable non-editable board selection inside responsive media blocks", () => {
const responsiveBoardRules = mediaBlocks(loadAllAppCss()).flatMap((block) => findRules(block, ".board"));
expect(responsiveBoardRules).not.toHaveLength(0);
for (const { declarations } of responsiveBoardRules) {
expect(declarations).not.toMatch(/(?:-webkit-)?user-select:\s*(?:text|auto)\s*;/);
}
});
it("keeps one broadly anchored suppression rule before its more-specific editable opt-in", () => {
const boardCss = loadComponentCss("Board.css");
const suppressionRules = findRules(boardCss, ".board *");
const optInRules = findRules(boardCss, ".board :is(");
expect(suppressionRules).toHaveLength(1);
expect(optInRules).toHaveLength(1);
expect(suppressionRules[0]!.selector).not.toContain("board-workflow-columns");
expect(suppressionRules[0]!.selector).not.toContain("board-workflows-skeleton");
expect(boardCss.indexOf(suppressionRules[0]!.selector)).toBeLessThan(boardCss.indexOf(optInRules[0]!.selector));
});
});

View File

@@ -44,11 +44,29 @@ The board needs a neutral first-paint shell whenever workflow lanes are enabled
inline-size: 75%;
}
/*
FNXC:BoardTextSelection 2026-08-27-10:06:
FN-194 keeps the intentional `useBoardMousePan` drag while removing the browser's second horizontal-scroll system: selection-autoscroll begins before its 4px pan threshold when text is dragged. The operator explicitly chose non-selectable Board text to prevent that involuntary scroll.
Anchor this at `.board`, rather than a workflow variant, so both live roots, the skeleton, every breakpoint, and every data state share the rule. The editable carve-out must remain more specific than suppression so inputs and inline card editing keep native text selection independently of CSS import order.
*/
.board,
.board * {
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.board :is(input, textarea, select, [contenteditable="true"], .card-editing, .card-editing *) {
user-select: text;
-webkit-user-select: text;
-webkit-touch-callout: default;
}
/*
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.
the disabled mobile path never activate it; this gesture pans the viewport and never relocates a task. Selection suppression is unconditional and owned by the BoardTextSelection rule above.
*/
.board.board-workflow-columns.is-mouse-panning {
cursor: grabbing;

View File

@@ -235,6 +235,11 @@ export function Board({ tasks, projectId, maxConcurrent, effectiveMaxConcurrent
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.
FNXC:BoardTextSelection 2026-08-27-10:06:
FN-194 makes selection suppression CSS-owned by the shared `.board` class before this 4px-intent
hook can capture a drag. Keep the class name and pan wiring unchanged so the intentional pan remains
the sole pointer-driven horizontal scroll path while editable descendants opt back in through Board.css.
*/
const { isPanning: isBoardMousePanning, ...boardMousePanBindings } = useBoardMousePan(boardElement, viewportMode !== "mobile");
const boardClassName = `board board-workflow-columns${isBoardMousePanning ? " is-mouse-panning" : ""}`;

View File

@@ -0,0 +1,144 @@
import React from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import type { Column as ColumnType, Task } from "@fusion/core";
import { loadComponentCss } from "../../test/cssFixture";
import { Column } from "../Column";
vi.mock("../TaskCard", () => ({
TaskCard: React.memo(({ task }: { task: Task }) => (
<div className={task.status === "editing" ? "card card-editing" : "card"} data-testid={`task-${task.id}`}>
<span data-testid={`task-text-${task.id}`}>{task.title}</span>
</div>
)),
}));
vi.mock("../WorktreeGroup", () => ({
WorktreeGroup: ({ label, kind, activeTasks, queuedTasks }: { label: string; kind: string; activeTasks: Task[]; queuedTasks: Task[] }) => (
<div data-testid="worktree-group" data-label={label} data-kind={kind} data-active-count={activeTasks.length} data-queued-count={queuedTasks.length}>
<span>{label}</span>
</div>
),
}));
vi.mock("../QuickEntryBox", () => ({
QuickEntryBox: () => <textarea aria-label="Quick create task" />,
}));
vi.mock("../../hooks/usePluginUiSlots", () => ({
usePluginUiSlots: () => ({ slots: [], getSlotsForId: () => [], loading: false, error: null }),
}));
vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({ confirm: vi.fn() }),
}));
vi.mock("lucide-react", () => ({
ChevronDown: () => null,
ChevronUp: () => null,
MoreVertical: () => null,
}));
function selectorFor(selectorFragment: string): string {
const pattern = /([^{}]+)\{[\s\S]*?\}/g;
const rule = [...loadComponentCss("Board.css").matchAll(pattern)].find((match) => match[1].includes(selectorFragment));
expect(rule).toBeDefined();
return rule![1].replace(/\/\*[\s\S]*?\*\//g, "").trim();
}
const suppressionSelector = selectorFor(".board *");
const editableOptInSelector = selectorFor(".board :is(");
function makeTask(id: string, overrides: Partial<Task> = {}): Task {
return {
id,
title: `Task ${id}`,
description: "Board selection test task",
column: "todo" as ColumnType,
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-08-27T00:00:00.000Z",
updatedAt: "2026-08-27T00:00:00.000Z",
...overrides,
} as Task;
}
const defaultProps = {
column: "todo" as ColumnType,
tasks: [] as Task[],
maxConcurrent: 2,
showWorktreeGrouping: false,
workflowMode: true,
workflowId: "builtin:coding",
columnDisplayName: "Todo",
columnDescription: "Tasks ready to start",
onMoveTask: vi.fn().mockResolvedValue({} as Task),
onOpenDetail: vi.fn(),
addToast: vi.fn(),
};
function renderBoardColumn(overrides: Partial<React.ComponentProps<typeof Column>> = {}) {
const board = document.createElement("main");
board.className = "board board-workflow-columns";
document.body.append(board);
return render(<Column {...defaultProps} {...overrides} />, { container: board });
}
function expectSuppressed(element: Element): void {
expect(element.matches(suppressionSelector)).toBe(true);
expect(element.matches(editableOptInSelector)).toBe(false);
}
afterEach(() => {
cleanup();
document.body.replaceChildren();
});
describe("Board text-selection selector bindings (FN-194)", () => {
it("binds the shipped suppression selector to the plain empty-column label", () => {
renderBoardColumn();
expectSuppressed(screen.getByText("No tasks"));
});
it("binds the shipped suppression selector to the worktree-grouped empty-column label", () => {
renderBoardColumn({ showWorktreeGrouping: true });
expectSuppressed(screen.getByText("No tasks"));
});
it("binds the shipped suppression selector to every visible column-chrome surface", () => {
renderBoardColumn();
expectSuppressed(screen.getByRole("heading", { name: "Todo", level: 2 }));
expectSuppressed(document.querySelector(".column-count")!);
expectSuppressed(document.querySelector(".column-desc")!);
});
it("preserves selectable quick-create and inline-card editing descendants", () => {
renderBoardColumn({
onQuickCreate: vi.fn().mockResolvedValue(undefined),
tasks: [makeTask("FN-editing", { status: "editing" as never })],
});
const textarea = screen.getByLabelText("Quick create task");
const editingText = screen.getByTestId("task-text-FN-editing");
expect(textarea.matches(editableOptInSelector)).toBe(true);
expect(editingText.matches(editableOptInSelector)).toBe(true);
});
it("does not leak suppression to matching elements outside the board", () => {
renderBoardColumn();
const outsideBoard = document.createElement("div");
outsideBoard.className = "empty-column";
outsideBoard.textContent = "No tasks outside board";
document.body.append(outsideBoard);
expect(outsideBoard.matches(suppressionSelector)).toBe(false);
});
});