diff --git a/.changeset/fn-194-board-text-selection.md b/.changeset/fn-194-board-text-selection.md new file mode 100644 index 0000000000..86335aa9b5 --- /dev/null +++ b/.changeset/fn-194-board-text-selection.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 1d8f3ac4d1..3d4a1b708c 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. -On desktop and tablet, an ordinary task-card click opens the configured task-detail destination (popup, right dock, or main panel). Drag horizontally from an empty Board surface or a task card's noninteractive body or text to pan the Board viewport only after the gesture crosses horizontal intent; that pan suppresses its compatibility click and does not open detail. The gesture never moves a task: card controls, links, fields, editing, context menus, and file drops keep their normal behavior, and **Move to** in the task context menu remains the only relocation path. Fusion does not auto-scroll at Board edges. Phone touch scrolling and column snapping are unchanged. + +On 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. 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: - Move a task from its context menu: right-click on desktop, use the overflow button or keyboard Context Menu/Shift+F10, or long-press on touch. When several legal destinations exist, open the grouped **Move to** submenu; a single destination remains a direct action. This changes task movement only. -- On desktop and tablet, click-hold and drag a safe Board surface, including empty-column text 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 diff --git a/packages/dashboard/app/__tests__/board-text-selection.test.ts b/packages/dashboard/app/__tests__/board-text-selection.test.ts new file mode 100644 index 0000000000..7f12f59da7 --- /dev/null +++ b/packages/dashboard/app/__tests__/board-text-selection.test.ts @@ -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)); + }); +}); diff --git a/packages/dashboard/app/components/Board.css b/packages/dashboard/app/components/Board.css index 55967b9901..c54cc46db2 100644 --- a/packages/dashboard/app/components/Board.css +++ b/packages/dashboard/app/components/Board.css @@ -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; diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index f987f39a8c..cd02bb66df 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -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" : ""}`; diff --git a/packages/dashboard/app/components/__tests__/Board.text-selection.test.tsx b/packages/dashboard/app/components/__tests__/Board.text-selection.test.tsx new file mode 100644 index 0000000000..7a416e54c6 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/Board.text-selection.test.tsx @@ -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 }) => ( +
+ {task.title} +
+ )), +})); + +vi.mock("../WorktreeGroup", () => ({ + WorktreeGroup: ({ label, kind, activeTasks, queuedTasks }: { label: string; kind: string; activeTasks: Task[]; queuedTasks: Task[] }) => ( +
+ {label} +
+ ), +})); + +vi.mock("../QuickEntryBox", () => ({ + QuickEntryBox: () =>