}));
+vi.mock("../../components/QuickChatFAB", () => ({ QuickChatFAB: () => null }));
+vi.mock("../../components/ScriptsModal", () => ({ ScriptsModal: () => null }));
+vi.mock("../../components/TerminalModal", () => ({ TerminalModal: () => null }));
+vi.mock("../../components/FileBrowser", () => ({ FileBrowserModal: () => null }));
+vi.mock("../../components/ActivityLogModal", () => ({ ActivityLogModal: () => null }));
+vi.mock("../../components/GitManagerModal", () => ({ GitManagerModal: () => null }));
+vi.mock("../../components/SchedulesModal", () => ({ SchedulesModal: () => null }));
+vi.mock("../../components/WorkflowEditorModal", () => ({ WorkflowEditorModal: () => null }));
+vi.mock("../../components/AgentsModal", () => ({ AgentsModal: () => null }));
+vi.mock("../../components/SubtaskBreakdownModal", () => ({ SubtaskBreakdownModal: () => null }));
+vi.mock("../../components/UsageModal", () => ({ UsageModal: () => null }));
+vi.mock("../../components/ModelOnboardingModal", () => ({ ModelOnboardingModal: () => null }));
+vi.mock("../../components/SetupWizardModal", () => ({ SetupWizardModal: () => null }));
+vi.mock("../../components/GroupTaskModal", () => ({ GroupTaskModal: () => null }));
+vi.mock("../../components/ProjectSelector", () => ({ ProjectSelector: () => }));
+vi.mock("../../components/ProjectCard", () => ({ ProjectCard: () => }));
+vi.mock("../../components/Sidebar", () => ({ Sidebar: () => }));
+vi.mock("../../components/Header", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ Header: () => ,
+ };
+});
+vi.mock("../../components/MobileNavBar", () => ({ MobileNavBar: () => null }));
+vi.mock("../../components/RightDock", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ RightDock: () => null,
+ RightDockExpandModal: () => null,
+ };
+});
+
+const mockUseProjects = vi.fn(() => ({ projects: [], loading: false, error: null }));
+const mockCurrentProjectState = {
+ currentProject: {
+ id: "proj-1",
+ name: "Test Project",
+ path: "/test",
+ status: "active",
+ isolationMode: "in-process",
+ createdAt: "",
+ updatedAt: "",
+ } as ProjectInfo,
+ loading: false,
+ setCurrentProject: vi.fn(),
+ clearCurrentProject: vi.fn(),
+};
+vi.mock("../../hooks/useProjects", () => ({ useProjects: () => mockUseProjects() }));
+vi.mock("../../hooks/useCurrentProject", () => ({
+ useCurrentProject: () => mockCurrentProjectState,
+}));
+vi.mock("../../hooks/useNodes", () => ({
+ useNodes: vi.fn(() => ({
+ nodes: [], loading: false, error: null,
+ refresh: vi.fn(), register: vi.fn(), update: vi.fn(), unregister: vi.fn(), healthCheck: vi.fn(),
+ })),
+}));
+
+const mockUseViewportMode = vi.fn(() => "desktop");
+vi.mock("../../hooks/useViewportMode", () => ({
+ MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
+ getViewportMode: () => mockUseViewportMode(),
+ isMobileViewport: () => mockUseViewportMode() === "mobile",
+ useViewportMode: (..._args: unknown[]) => mockUseViewportMode(..._args),
+}));
+
+const mockUseMobileKeyboard = vi.fn(() => ({
+ keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false,
+}));
+vi.mock("../../hooks/useMobileKeyboard", () => ({
+ useMobileKeyboard: (..._args: unknown[]) => mockUseMobileKeyboard(..._args),
+}));
+
+import { App } from "../../App";
+
+function makeBoardTask(id: string, title: string): Task {
+ return {
+ id,
+ title,
+ description: "Test task description",
+ column: "todo",
+ status: "todo",
+ createdAt: new Date(0).toISOString(),
+ updatedAt: new Date(0).toISOString(),
+ } as Task;
+}
+
+async function renderAppAndWait(expectedTestId: string = "board-view") {
+ const result = render();
+ await waitFor(() => {
+ expect(screen.getByTestId(expectedTestId)).toBeTruthy();
+ });
+ return result;
+}
+
+describe("Board main-panel task-detail — mobile transition class gating (MainContent.tsx)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockSubscribeSse.mockReset();
+ mockSubscribeSse.mockReturnValue(vi.fn());
+ mockUseTasks.mockReset();
+ });
+
+ it("applies the mobile transition class to the board main-panel surface when the viewport is mobile", async () => {
+ mockUseViewportMode.mockReturnValue("mobile");
+ const task = makeBoardTask("FN-1", "Board Detail");
+ mockUseTasks.mockImplementation(() => ({
+ tasks: [task],
+ createTask: mockCreateTask,
+ moveTask: vi.fn(),
+ deleteTask: vi.fn(),
+ mergeTask: vi.fn(),
+ retryTask: vi.fn(),
+ updateTask: vi.fn(),
+ duplicateTask: vi.fn(),
+ archiveTask: vi.fn(),
+ unarchiveTask: vi.fn(),
+ archiveAllDone: vi.fn(),
+ refreshTasks: vi.fn(),
+ }));
+
+ await renderAppAndWait("board-view");
+ fireEvent.click(screen.getByTestId("open-task-FN-1"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("task-detail-main-panel-content")).toBeInTheDocument();
+ });
+ expect(document.querySelector(".task-detail-main-panel--mobile-transition")).toBeInTheDocument();
+ });
+
+ it("does NOT apply the mobile transition class to the board main-panel surface on desktop", async () => {
+ mockUseViewportMode.mockReturnValue("desktop");
+ const task = makeBoardTask("FN-1", "Board Detail");
+ mockUseTasks.mockImplementation(() => ({
+ tasks: [task],
+ createTask: mockCreateTask,
+ moveTask: vi.fn(),
+ deleteTask: vi.fn(),
+ mergeTask: vi.fn(),
+ retryTask: vi.fn(),
+ updateTask: vi.fn(),
+ duplicateTask: vi.fn(),
+ archiveTask: vi.fn(),
+ unarchiveTask: vi.fn(),
+ archiveAllDone: vi.fn(),
+ refreshTasks: vi.fn(),
+ }));
+
+ await renderAppAndWait("board-view");
+ fireEvent.click(screen.getByTestId("open-task-FN-1"));
+
+ await waitFor(() => {
+ expect(screen.getByTestId("task-detail-main-panel-content")).toBeInTheDocument();
+ });
+ expect(document.querySelector(".task-detail-main-panel")).toBeInTheDocument();
+ expect(document.querySelector(".task-detail-main-panel--mobile-transition")).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/dashboard/app/components/__tests__/TaskDetail.mobile-transition.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetail.mobile-transition.test.tsx
new file mode 100644
index 0000000000..f79052fdf1
--- /dev/null
+++ b/packages/dashboard/app/components/__tests__/TaskDetail.mobile-transition.test.tsx
@@ -0,0 +1,156 @@
+/**
+ * Focused regression coverage for the FN-7587 mobile task-detail predictive-back
+ * slide/fade transition polish.
+ *
+ * FNXC:TaskDetailSwipeBack 2026-07-05-12:45:
+ * This suite asserts ONLY the presentation-layer invariant this task adds:
+ * - the mobile transition class is applied to the modal/list/nested surface when the
+ * viewport is mobile;
+ * - the class is absent on desktop, and does not linger after re-rendering desktop-width;
+ * - the CSS neutralizes the animation under `prefers-reduced-motion: reduce`
+ * (jsdom cannot execute CSS keyframe animations, so this is asserted statically
+ * against the stylesheet source, mirroring the project's existing
+ * `TaskDetailModal.css.test.ts` / `TaskDetailModal.github-tracking-enable.css.test.ts`
+ * pattern of asserting CSS text rather than computed animation state).
+ *
+ * Board main-panel gating (MainContent.tsx) is covered separately in
+ * `TaskDetail.mobile-transition.board-panel.test.tsx` because that surface requires the
+ * full App-level mock harness (Board/ListView/TaskDetailModal module mocks + real
+ * lucide-react icons via Header), which conflicts with this file's TaskDetailModal-focused
+ * `test-helpers` harness (fixed lucide-react icon allowlist) if combined in one module.
+ *
+ * This suite deliberately does NOT re-derive dismissal-routing coverage — that remains the
+ * sole responsibility of `TaskDetail.swipe-back.test.tsx` and `navigation-history.test.tsx`,
+ * which this task runs unmodified (see PROMPT.md Step 0/3) to prove the animation layer does
+ * not perturb the `useNavigationHistory` / `popstate` / `fusion:native-back` invariant.
+ */
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import {
+ makeTask,
+ noop,
+ noopMove,
+ noopDelete,
+ noopMerge,
+ noopOpenDetail,
+ setupTaskDetailModalHooks,
+} from "./TaskDetailModal.test-helpers";
+import { TaskDetailModal } from "../TaskDetailModal";
+
+const MOBILE_WIDTH = 375;
+const DESKTOP_WIDTH = 1024;
+
+function setViewportWidth(width: number): void {
+ Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: width });
+ window.dispatchEvent(new Event("resize"));
+}
+
+describe("Task-detail mobile predictive-back transition — CSS invariants", () => {
+ it("styles.css neutralizes the board main-panel transition under prefers-reduced-motion", () => {
+ const css = readFileSync(resolve(__dirname, "../../styles.css"), "utf8");
+ expect(css).toContain(".task-detail-main-panel--mobile-transition");
+ expect(css).toContain("@keyframes task-detail-mobile-slide-fade-in");
+ const reducedMotionBlock = css.slice(css.indexOf("@media (prefers-reduced-motion: reduce) {\n .task-detail-main-panel--mobile-transition"));
+ expect(reducedMotionBlock.slice(0, 200)).toContain("animation: none;");
+ });
+
+ it("TaskDetailModal.css neutralizes the modal/list/nested transition under prefers-reduced-motion", () => {
+ const css = readFileSync(resolve(__dirname, "../TaskDetailModal.css"), "utf8");
+ expect(css).toContain(".task-detail-modal--mobile-transition");
+ expect(css).toContain("@keyframes task-detail-modal-mobile-slide-fade-in");
+ const reducedMotionBlock = css.slice(css.indexOf("@media (prefers-reduced-motion: reduce) {\n .task-detail-modal--mobile-transition"));
+ expect(reducedMotionBlock.slice(0, 200)).toContain("animation: none;");
+ });
+});
+
+describe("TaskDetailModal wrapper — mobile transition class gating (modal/list/nested surface)", () => {
+ setupTaskDetailModalHooks();
+
+ beforeEach(() => {
+ setViewportWidth(MOBILE_WIDTH);
+ });
+
+ afterEach(() => {
+ setViewportWidth(DESKTOP_WIDTH);
+ });
+
+ it("applies the mobile transition class to the modal surface when the viewport is mobile", async () => {
+ setViewportWidth(MOBILE_WIDTH);
+
+ render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(document.querySelector(".task-detail-modal--mobile-transition")).toBeInTheDocument();
+ });
+ });
+
+ it("does NOT apply the mobile transition class on desktop", async () => {
+ setViewportWidth(DESKTOP_WIDTH);
+
+ render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(document.querySelector(".task-detail-modal")).toBeInTheDocument();
+ });
+ expect(document.querySelector(".task-detail-modal--mobile-transition")).not.toBeInTheDocument();
+ });
+
+ it("does not leave a lingering transition class after re-rendering at desktop width", async () => {
+ setViewportWidth(MOBILE_WIDTH);
+
+ const { rerender } = render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(document.querySelector(".task-detail-modal--mobile-transition")).toBeInTheDocument();
+ });
+
+ setViewportWidth(DESKTOP_WIDTH);
+ rerender(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(document.querySelector(".task-detail-modal--mobile-transition")).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx
index 403408d45f..428ba00ac4 100644
--- a/packages/dashboard/app/components/dashboard/MainContent.tsx
+++ b/packages/dashboard/app/components/dashboard/MainContent.tsx
@@ -751,7 +751,15 @@ export function MainContent({
}
return (
-
+ {/*
+ FNXC:TaskDetailSwipeBack 2026-07-05-12:30:
+ FN-7587 — presentation-only predictive-back polish layered on top of the unchanged
+ FN-7583/FN-7586 dismissal routing (popstate / fusion:native-back / useNavigationHistory
+ stack). The `--mobile-transition` modifier only adds a CSS enter animation gated to the
+ existing `isMobile` prop; it never defers or reorders when onRequestClose/onBackToBoard
+ fire, and honors prefers-reduced-motion (see styles.css).
+ */}
+