diff --git a/.changeset/fn-8235-mobile-kanban-snap.md b/.changeset/fn-8235-mobile-kanban-snap.md new file mode 100644 index 0000000000..0c16095060 --- /dev/null +++ b/.changeset/fn-8235-mobile-kanban-snap.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Mobile Kanban board now magnetically snaps to a single column when you swipe between columns. +category: fix +dev: New app/hooks/useColumnScrollSnap.ts scroll-end snap wired to Board #board; keeps CSS scroll-snap-type: x proximity (no mandatory) to preserve the FN-001 corner-rendering fix. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index e6c12c1ac7..825568fc81 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -80,6 +80,12 @@ On mobile, an open navigation-bar **More** sheet or mailbox message detail is di When task detail is open from a board card, task popup, mobile list row, right-dock/activity/onboarding link, deep link, or another task detail link, one browser, iOS edge-swipe, or Android Back action closes the current detail first and restores the prior dashboard context (for example, nested task detail → previous task detail, or task detail → board/list). 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 + +After a horizontal swipe on the mobile Kanban board, Fusion smoothly settles the viewport on the nearest column so it does not rest between two columns. This is a user-scroll-end behavior only; refreshes, resizes, and restored pages preserve the column position you chose. The board intentionally keeps CSS `scroll-snap-type: x proximity` rather than using `x mandatory`, because mandatory snapping reintroduced the FN-001 iOS corner-rendering regression during layout changes. + + This behavior used to be mobile-only, and now applies across all viewports. Task Detail modal opens from onboarding, activity log, and task-to-task navigation now all register navigation history entries, so Android back swipe/button dismisses them consistently. diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index b632751c54..985ed42c50 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -9,6 +9,7 @@ import { useState, useMemo, useEffect, useCallback, useRef } from "react"; import { createPortal } from "react-dom"; import { promoteTask, type ModelInfo, type BoardWorkflowsPayload, type BoardWorkflowColumn, type RevertTaskOptions, type RevertTaskResult } from "../api"; import { useBlockerFanout } from "../hooks/useBlockerFanout"; +import { useColumnScrollSnap } from "../hooks/useColumnScrollSnap"; import { MOBILE_MEDIA_QUERY, useViewportMode } from "../hooks/useViewportMode"; import { recordResumeEvent } from "../utils/resumeInstrumentation"; import { getBoardCanDropTaskRejection } from "./boardCanDropTask"; @@ -178,6 +179,18 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o ); const archivedLoadedRef = useRef(false); const boardRef = useRef(null); + const [boardElement, setBoardElement] = useState(null); + /* + FNXC:BoardNavigation 2026-07-16-00:00: + The board can first render a workflow-loading skeleton, so a mutable ref alone would not + re-run the snap effect after the live board mounts. Mirror the callback ref in state to attach + user-only mobile snapping to every live Board variant without snapping the skeleton. + */ + const setBoardRef = useCallback((element: HTMLElement | null) => { + boardRef.current = element; + setBoardElement((current) => current === element ? current : element); + }, []); + useColumnScrollSnap(boardElement, { mobileOnly: true }); const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState(() => { if (typeof document === "undefined") return null; return document.getElementById("header-workflow-slot"); @@ -927,7 +940,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o return (
{renderedWorkflowToolbar} -
+
{aggregateRenderedBoardColumns.map((columnDef) => { const isCreateColumn = aggregateQuickCreateTarget?.columnId === columnDef.id; const isDoneLikeColumn = columnDef.flags.complete === true && columnDef.flags.archived !== true; @@ -999,7 +1012,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
{ const id = (e.target as HTMLElement)?.closest?.("[data-id]")?.getAttribute("data-id"); if (id) draggingTaskIdRef.current = id; @@ -1134,7 +1147,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o return ( <> -
+
{COLUMNS.map((col) => ( { +describe("mobile board magnetic column snap wiring (FN-8235)", () => { + it("shares the mobile scroll-end hook across legacy, selected, and aggregate live board renders", () => { + const boardSource = fs.readFileSync(path.join(process.cwd(), "app/components/Board.tsx"), "utf8"); + + expect(boardSource).toContain('import { useColumnScrollSnap } from "../hooks/useColumnScrollSnap";'); + expect(boardSource).toContain("useColumnScrollSnap(boardElement, { mobileOnly: true });"); + expect(boardSource.match(/ref=\{setBoardRef\}/g)).toHaveLength(3); + expect(boardSource.match(/className="board board-workflow-columns"/g)).toHaveLength(2); + expect(boardSource).toContain('
'); + }); +}); + +describe("getMainMobileSection — compound media queries", () => { it("matches simple and compound 768px blocks but excludes other breakpoints", () => { const syntheticCss = ` @media (max-width: 768px) diff --git a/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts b/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts new file mode 100644 index 0000000000..fa36f8d541 --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useColumnScrollSnap.test.ts @@ -0,0 +1,159 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useColumnScrollSnap } from "../useColumnScrollSnap"; +import { isMobileViewport } from "../useViewportMode"; + +type Viewport = "mobile" | "wide-short-desktop"; + +function stubViewport(viewport: Viewport): void { + const isMobile = viewport === "mobile"; + vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ + matches: query === "(max-width: 768px)" ? isMobile : query === "(max-height: 480px)", + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(() => true), + }))); + Object.defineProperty(window, "screen", { + configurable: true, + value: viewport === "mobile" ? { width: 390, height: 844 } : { width: 1920, height: 1080 }, + }); + Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: viewport === "mobile" ? 1 : 0 }); + vi.stubGlobal("visualViewport", { + width: viewport === "mobile" ? 390 : 1200, + height: viewport === "mobile" ? 844 : 400, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); +} + +function createScroller(columnCount = 2): HTMLElement { + const scroller = document.createElement("main"); + Object.defineProperty(scroller, "clientWidth", { configurable: true, value: 100 }); + scroller.getBoundingClientRect = () => new DOMRect(0, 0, 100, 200); + Object.defineProperty(scroller, "scrollTo", { configurable: true, value: vi.fn() }); + for (let index = 0; index < columnCount; index++) { + const column = document.createElement("section"); + const left = index === 0 ? -60 : 30; + column.getBoundingClientRect = () => new DOMRect(left, 0, 100, 200); + scroller.append(column); + } + document.body.append(scroller); + return scroller; +} + +function dispatchUserPan(scroller: HTMLElement): void { + scroller.dispatchEvent(new Event("pointerdown")); + scroller.scrollLeft = 10; + scroller.dispatchEvent(new Event("scroll")); + scroller.dispatchEvent(new Event("scrollend")); +} + +describe("useColumnScrollSnap", () => { + beforeEach(() => { + vi.useFakeTimers(); + stubViewport("mobile"); + }); + + afterEach(() => { + document.body.replaceChildren(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("centers the nearest column after verified user horizontal movement ends", () => { + const scroller = createScroller(); + renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => dispatchUserPan(scroller)); + + expect(scroller.scrollTo).toHaveBeenCalledWith({ left: 40, behavior: "smooth" }); + }); + + it("attaches after a loading skeleton is replaced by the live board", () => { + const scroller = createScroller(); + const { rerender } = renderHook( + ({ element }) => useColumnScrollSnap(element, { mobileOnly: true, isUserInteraction: () => true }), + { initialProps: { element: null as HTMLElement | null } }, + ); + + rerender({ element: scroller }); + act(() => dispatchUserPan(scroller)); + + expect(scroller.scrollTo).toHaveBeenCalledWith({ left: 40, behavior: "smooth" }); + }); + + it("does not snap on mount, viewport lifecycle events, or programmatic scrolling", () => { + const scroller = createScroller(); + renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => { + window.dispatchEvent(new Event("resize")); + window.dispatchEvent(new Event("pageshow")); + scroller.scrollLeft = 40; + scroller.dispatchEvent(new Event("scroll")); + scroller.dispatchEvent(new Event("scrollend")); + vi.advanceTimersByTime(500); + }); + + expect(scroller.scrollTo).not.toHaveBeenCalled(); + }); + + it("requires horizontal movement after user input rather than a recent tap", () => { + const scroller = createScroller(); + renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => { + scroller.dispatchEvent(new Event("pointerdown")); + scroller.dispatchEvent(new Event("scrollend")); + vi.advanceTimersByTime(500); + }); + + expect(scroller.scrollTo).not.toHaveBeenCalled(); + }); + + it.each([0, 1])("does nothing with %s snap children", (columnCount) => { + const scroller = createScroller(columnCount); + renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => dispatchUserPan(scroller)); + + expect(scroller.scrollTo).not.toHaveBeenCalled(); + }); + + it("does not attach magnetic snapping on a wide, short non-phone desktop", () => { + stubViewport("wide-short-desktop"); + expect(window.screen.width).toBe(1920); + expect(window.screen.height).toBe(1080); + expect(window.visualViewport?.width).toBe(1200); + expect(window.matchMedia("(max-width: 768px)").matches).toBe(false); + expect(window.matchMedia("(max-height: 480px)").matches).toBe(true); + expect(isMobileViewport()).toBe(false); + const scroller = createScroller(); + const addListener = vi.spyOn(scroller, "addEventListener"); + renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => dispatchUserPan(scroller)); + + expect(addListener).not.toHaveBeenCalledWith("scrollend", expect.any(Function)); + expect(scroller.scrollTo).not.toHaveBeenCalled(); + }); + + it("ignores scroll activity while its own smooth snap is in progress", () => { + const scroller = createScroller(); + renderHook(() => useColumnScrollSnap(scroller, { mobileOnly: true, isUserInteraction: () => true })); + + act(() => { + dispatchUserPan(scroller); + scroller.dispatchEvent(new Event("scroll")); + scroller.dispatchEvent(new Event("scrollend")); + dispatchUserPan(scroller); + }); + + expect(scroller.scrollTo).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/dashboard/app/hooks/useColumnScrollSnap.ts b/packages/dashboard/app/hooks/useColumnScrollSnap.ts new file mode 100644 index 0000000000..33dab89131 --- /dev/null +++ b/packages/dashboard/app/hooks/useColumnScrollSnap.ts @@ -0,0 +1,179 @@ +import { useEffect, useState } from "react"; +import { isMobileViewport } from "./useViewportMode"; + +const SCROLL_IDLE_DELAY_MS = 120; +const SNAP_RELEASE_DELAY_MS = 300; +const CENTER_TOLERANCE_PX = 1; + +export interface UseColumnScrollSnapOptions { + /** Restrict magnetic snapping to phone-class viewports. */ + mobileOnly?: boolean; + /** Test seam; production callers must use the default trusted-event predicate. */ + isUserInteraction?: (event: Event) => boolean; +} + +function defaultIsUserInteraction(event: Event): boolean { + return event.isTrusted; +} + +function addMediaChangeListener(query: MediaQueryList, listener: () => void): () => void { + if (typeof query.addEventListener === "function") { + query.addEventListener("change", listener); + return () => query.removeEventListener("change", listener); + } + query.addListener(listener); + return () => query.removeListener(listener); +} + +/** + * Adds a mobile-only, user-driven scroll-end snap to a horizontal column scroller. + * + * FNXC:BoardNavigation 2026-07-15-13:30: + * Mobile Kanban swipes must settle on one column, but CSS `scroll-snap-type: x mandatory` + * regressed FN-001 by snapping against stale iOS layout metrics. Preserve proximity CSS and + * snap only after verified user horizontal movement has ended; mount, reflow, resize, pageshow, + * and programmatic scrolling must never choose a board column. + */ +export function useColumnScrollSnap( + scroller: HTMLElement | null, + { mobileOnly = false, isUserInteraction = defaultIsUserInteraction }: UseColumnScrollSnapOptions = {}, +): void { + const [isEligibleViewport, setIsEligibleViewport] = useState(() => !mobileOnly || isMobileViewport()); + + useEffect(() => { + if (!mobileOnly || typeof window === "undefined") return; + + const updateEligibility = () => setIsEligibleViewport(isMobileViewport()); + const widthQuery = window.matchMedia("(max-width: 768px)"); + const heightQuery = window.matchMedia("(max-height: 480px)"); + const removeWidthListener = addMediaChangeListener(widthQuery, updateEligibility); + const removeHeightListener = addMediaChangeListener(heightQuery, updateEligibility); + const visualViewport = window.visualViewport; + + window.addEventListener("resize", updateEligibility); + window.addEventListener("orientationchange", updateEligibility); + visualViewport?.addEventListener("resize", updateEligibility); + updateEligibility(); + + return () => { + removeWidthListener(); + removeHeightListener(); + window.removeEventListener("resize", updateEligibility); + window.removeEventListener("orientationchange", updateEligibility); + visualViewport?.removeEventListener("resize", updateEligibility); + }; + }, [mobileOnly]); + + useEffect(() => { + if (!scroller || !isEligibleViewport) return; + + let interactionActive = false; + let interactionScrollLeft = scroller.scrollLeft; + let sawHorizontalMovement = false; + let isSnapping = false; + let idleTimer: ReturnType | null = null; + let snapReleaseTimer: ReturnType | null = null; + + const clearIdleTimer = () => { + if (idleTimer !== null) clearTimeout(idleTimer); + idleTimer = null; + }; + + const releaseSnap = () => { + if (snapReleaseTimer !== null) clearTimeout(snapReleaseTimer); + snapReleaseTimer = setTimeout(() => { + isSnapping = false; + snapReleaseTimer = null; + }, SNAP_RELEASE_DELAY_MS); + }; + + const snapToNearestColumn = () => { + clearIdleTimer(); + if (!interactionActive || !sawHorizontalMovement || isSnapping) return; + interactionActive = false; + sawHorizontalMovement = false; + + const columns = Array.from(scroller.children) as HTMLElement[]; + if (columns.length < 2) return; + + const scrollerRect = scroller.getBoundingClientRect(); + const viewportWidth = scroller.clientWidth || scrollerRect.width; + if (viewportWidth <= 0) return; + + const viewportCenter = scrollerRect.left + viewportWidth / 2; + let nearestColumn: HTMLElement | null = null; + let nearestDistance = Number.POSITIVE_INFINITY; + for (const column of columns) { + const rect = column.getBoundingClientRect(); + const distance = Math.abs(rect.left + rect.width / 2 - viewportCenter); + if (distance < nearestDistance) { + nearestColumn = column; + nearestDistance = distance; + } + } + if (!nearestColumn || nearestDistance <= CENTER_TOLERANCE_PX) return; + + const columnRect = nearestColumn.getBoundingClientRect(); + const targetLeft = scroller.scrollLeft + columnRect.left + columnRect.width / 2 - viewportCenter; + isSnapping = true; + if (typeof scroller.scrollTo === "function") { + scroller.scrollTo({ left: targetLeft, behavior: "smooth" }); + } else { + scroller.scrollLeft = targetLeft; + } + releaseSnap(); + }; + + const beginInteraction = (event: Event) => { + if (isSnapping || !isUserInteraction(event)) return; + interactionActive = true; + sawHorizontalMovement = false; + interactionScrollLeft = scroller.scrollLeft; + }; + + const handleScroll = () => { + if (isSnapping || !interactionActive) return; + if (scroller.scrollLeft === interactionScrollLeft) return; + sawHorizontalMovement = true; + interactionScrollLeft = scroller.scrollLeft; + clearIdleTimer(); + idleTimer = setTimeout(snapToNearestColumn, SCROLL_IDLE_DELAY_MS); + }; + + const handleInteractionEnd = () => { + if (!interactionActive || !sawHorizontalMovement || isSnapping) return; + clearIdleTimer(); + idleTimer = setTimeout(snapToNearestColumn, SCROLL_IDLE_DELAY_MS); + }; + + const handleScrollEnd = () => { + if (isSnapping) { + isSnapping = false; + if (snapReleaseTimer !== null) clearTimeout(snapReleaseTimer); + snapReleaseTimer = null; + return; + } + snapToNearestColumn(); + }; + + scroller.addEventListener("pointerdown", beginInteraction); + scroller.addEventListener("touchstart", beginInteraction); + scroller.addEventListener("wheel", beginInteraction, { passive: true }); + scroller.addEventListener("scroll", handleScroll, { passive: true }); + scroller.addEventListener("scrollend", handleScrollEnd); + scroller.addEventListener("pointerup", handleInteractionEnd); + scroller.addEventListener("touchend", handleInteractionEnd); + + return () => { + clearIdleTimer(); + if (snapReleaseTimer !== null) clearTimeout(snapReleaseTimer); + scroller.removeEventListener("pointerdown", beginInteraction); + scroller.removeEventListener("touchstart", beginInteraction); + scroller.removeEventListener("wheel", beginInteraction); + scroller.removeEventListener("scroll", handleScroll); + scroller.removeEventListener("scrollend", handleScrollEnd); + scroller.removeEventListener("pointerup", handleInteractionEnd); + scroller.removeEventListener("touchend", handleInteractionEnd); + }; + }, [isEligibleViewport, isUserInteraction, scroller]); +}