FN-7587: add predictive-back slide/fade animation for mobile task-detail dismissal

Adds a presentation-only enter animation for mobile task-detail surfaces (modal and board main-panel), layered on top of the existing FN-7583/FN-7586 dismissal routing, without altering close/back timing.

- Gate a new `.task-detail-modal--mobile-transition` class in TaskDetailModal.tsx via a local resize listener at the 768px breakpoint, mirroring the existing OVERSIGHT_MENU_MOBILE_BREAKPOINT pattern
- Add matching `.task-detail-main-panel--mobile-transition` modifier in MainContent.tsx gated by the existing isMobile prop
- Add slide/fade keyframe animations in TaskDetailModal.css and styles.css, both honoring prefers-reduced-motion
- Add regression tests covering the modal and board-panel mobile transition behavior
- Document the Capacitor WebView limitation preventing a true interactive predictive-back in packages/mobile/README.md

Files changed:
 .../dashboard/app/components/TaskDetailModal.css   |  33 ++
 .../dashboard/app/components/TaskDetailModal.tsx   |  31 +-
 ...skDetail.mobile-transition.board-panel.test.tsx | 333 +++++++++++++++++++++
 .../TaskDetail.mobile-transition.test.tsx          | 156 ++++++++++
 .../app/components/dashboard/MainContent.tsx       |  10 +-
 packages/dashboard/app/styles.css                  |  35 +++
 packages/mobile/README.md                          |  30 ++
 7 files changed, 626 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-7587

Fusion-Task-Lineage: cc5f08df-4aaf-447d-9c30-237b32191d3f

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-05 12:38:01 -07:00
parent da36094d74
commit 8a7507ad36
7 changed files with 626 additions and 2 deletions

View File

@@ -1715,6 +1715,39 @@ FN-6500 fixes a tablet regression from FN-5599: the task-detail overlay offset a
}
/*
FNXC:TaskDetailSwipeBack 2026-07-05-12:30:
FN-7587 — non-interactive predictive-back polish: a short slide/fade enter animation for the
mobile list/modal/nested task-detail surface, layered purely on top of the unchanged
FN-7583/FN-7586 dismissal routing (popstate / fusion:native-back / useNavigationHistory stack).
Gated to mobile via `.task-detail-modal--mobile-transition` (TaskDetailModal.tsx local resize
listener); desktop never receives this class. A true finger-tracked interactive predictive-back
is not feasible from a Capacitor single-page WebView today — see task FN-7587 notes.
*/
@media (max-width: 768px) {
.task-detail-modal--mobile-transition {
animation: task-detail-modal-mobile-slide-fade-in var(--duration-normal) ease-out;
}
}
@keyframes task-detail-modal-mobile-slide-fade-in {
from {
opacity: 0;
transform: translateX(var(--space-xl, 24px));
}
to {
opacity: 1;
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.task-detail-modal--mobile-transition {
animation: none;
}
}
.detail-actions-menu-item-danger {
color: var(--color-error);
}

View File

@@ -88,6 +88,8 @@ const ACTIVITY_VIEW_MENU_MAX_HEIGHT = 320;
const ACTIVITY_VIEW_MENU_OPEN_VIEWPORT_GUARD_MS = 350;
// FNXC:PlannerOversight 2026-07-04-19:00: FN-7545 — mobile breakpoint for collapsing the oversight action cluster into an overflow menu; matches the `@media (max-width: 768px)` breakpoint used across TaskDetailModal.css.
const OVERSIGHT_MENU_MOBILE_BREAKPOINT = 768;
// FNXC:TaskDetailSwipeBack 2026-07-05-12:30: FN-7587 — mobile breakpoint gating the presentation-only predictive-back slide/fade transition on the modal/list/nested task-detail surface; matches OVERSIGHT_MENU_MOBILE_BREAKPOINT/the `@media (max-width: 768px)` convention already used in this file.
const TASK_DETAIL_MOBILE_TRANSITION_BREAKPOINT = 768;
type ActivityViewMenuPosition = {
top: number;
@@ -5997,6 +5999,30 @@ export function TaskDetailModal({ onClose, ...props }: TaskDetailModalProps) {
useModalResizePersist(modalRef, true, "task-detail-modal-size");
useMobileScrollLock(true);
const overlayDismissProps = useOverlayDismiss(onClose);
/*
FNXC:TaskDetailSwipeBack 2026-07-05-12:30:
FN-7587 — track the mobile breakpoint locally (mirrors the OVERSIGHT_MENU_MOBILE_BREAKPOINT
resize-listener pattern above) so the list/modal/nested task-detail surface gets the same
presentation-only predictive-back slide/fade enter transition as the board main-panel
(MainContent.tsx), without threading a new isMobile prop through App.tsx/AppModals.tsx. This
is presentation-only: it never touches onClose/onRequestClose timing or the underlying
useNavigationHistory dismissal routing, and honors prefers-reduced-motion (see
TaskDetailModal.css). Defaults false so JSDOM/unit tests keep exercising the desktop (no
animation) branch unless a test explicitly narrows the viewport.
*/
const [isMobileTransition, setIsMobileTransition] = useState(false);
useEffect(() => {
const updateIsMobileTransition = () => {
setIsMobileTransition(window.innerWidth <= TASK_DETAIL_MOBILE_TRANSITION_BREAKPOINT);
};
updateIsMobileTransition();
window.addEventListener("resize", updateIsMobileTransition);
return () => {
window.removeEventListener("resize", updateIsMobileTransition);
};
}, []);
return (
<div
@@ -6005,7 +6031,10 @@ export function TaskDetailModal({ onClose, ...props }: TaskDetailModalProps) {
role="dialog"
aria-modal="true"
>
<div className="modal modal-lg task-detail-modal" ref={modalRef}>
<div
className={`modal modal-lg task-detail-modal${isMobileTransition ? " task-detail-modal--mobile-transition" : ""}`}
ref={modalRef}
>
<TaskDetailContent
{...props}
onRequestClose={onClose}

View File

@@ -0,0 +1,333 @@
/**
* FN-7587 companion suite: board main-panel (MainContent.tsx) mobile predictive-back
* transition class gating.
*
* FNXC:TaskDetailSwipeBack 2026-07-05-12:45:
* Split out from `TaskDetail.mobile-transition.test.tsx` because this surface needs the
* full App-level mock harness (mirrors `TaskDetail.swipe-back.test.tsx`'s harness: Board/
* ListView/TaskDetailModal module mocks, real lucide-react icons via Header) which conflicts
* with the TaskDetailModal-focused `test-helpers` harness's fixed lucide-react icon allowlist
* if both are combined in one test module (vi.mock hoisting collides).
*
* Asserts only the class-gating invariant: `.task-detail-main-panel--mobile-transition` is
* present on mobile and absent on desktop. Does not re-derive dismissal-routing coverage,
* which stays the responsibility of `TaskDetail.swipe-back.test.tsx` /
* `navigation-history.test.tsx` (run unmodified per PROMPT.md).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import type { Settings, Task } from "@fusion/core";
import type { ProjectInfo } from "../../api";
const defaultSettings: Settings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: true,
recycleWorktrees: false,
worktreeInitCommand: "",
testCommand: "",
buildCommand: "",
experimentalFeatures: { insights: true, roadmap: true, skillsView: true, agentsView: true, evalsView: true, todoView: true, leftSidebarNav: false, rightDock: false },
};
const mockSubscribeSse = vi.fn((..._args: any[]) => vi.fn());
vi.mock("../../sse-bus", () => ({
subscribeSse: (...args: any[]) => mockSubscribeSse(...args),
}));
vi.mock("../../api", async (importOriginal) => {
const { createDashboardApiMock } = await import("../../test/mockApi");
return createDashboardApiMock(() => importOriginal<typeof import("../../api")>(), {
fetchTasks: vi.fn(() => Promise.resolve([])),
fetchConfig: vi.fn(() => Promise.resolve({ maxConcurrent: 2, rootDir: "/workspace/project" })),
fetchSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
updateSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
fetchGlobalSettings: vi.fn(() => Promise.resolve({})),
fetchAuthStatus: vi.fn(() => Promise.resolve({ providers: [] })),
fetchModels: vi.fn(() => Promise.resolve({ models: [], favoriteProviders: [], favoriteModels: [] })),
fetchGitRemotes: vi.fn(() => Promise.resolve([])),
fetchAgents: vi.fn(() => Promise.resolve([])),
fetchTaskDetail: vi.fn((id: string) => Promise.resolve({ id, title: `Task ${id}` })),
fetchUnreadCount: vi.fn(() => Promise.resolve({ unreadCount: 0 })),
fetchPluginDashboardViews: vi.fn(() => Promise.resolve([])),
fetchExecutorStats: vi.fn(() => Promise.resolve({
globalPause: false,
enginePaused: false,
maxConcurrent: 2,
lastActivityAt: new Date().toISOString(),
})),
fetchScripts: vi.fn(() => Promise.resolve({})),
runScript: vi.fn(() => Promise.resolve({ sessionId: "sess-1", command: "echo" })),
killPtyTerminalSession: vi.fn(() => Promise.resolve({ killed: true })),
});
});
const mockCreateTask = vi.fn();
const mockUseTasks = vi.fn(() => ({
tasks: [],
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(),
}));
vi.mock("../../hooks/useTasks", () => ({
useTasks: (_options?: any) => mockUseTasks(),
}));
vi.mock("../../hooks/useInsights", () => ({
useInsights: () => ({
sections: [], loading: false, error: null, latestRun: null,
isRunInFlight: false, runError: null, refresh: vi.fn(),
runInsights: vi.fn(), dismiss: vi.fn(), createTask: vi.fn(),
dismissStates: new Map(), createTaskStates: new Map(),
totalCount: 0, dismissedCount: 0,
}),
}));
vi.mock("../../hooks/useRemoteNodeData", () => ({
useRemoteNodeData: vi.fn(() => ({
projects: [], tasks: [], health: null, loading: false,
error: null, refresh: vi.fn(),
})),
}));
vi.mock("../../hooks/useRemoteNodeEvents", () => ({
useRemoteNodeEvents: vi.fn(() => ({ isConnected: false, lastEvent: null })),
}));
vi.mock("../../hooks/useBackgroundSessions", () => ({
useBackgroundSessions: vi.fn(() => ({
sessions: [], generating: false, needsInput: false,
planningSessions: [], dismissSession: vi.fn(),
})),
}));
const mockNodeContextValue = {
currentNode: null, currentNodeId: null, isRemote: false,
setCurrentNode: vi.fn(), clearCurrentNode: vi.fn(),
};
vi.mock("../../context/NodeContext", () => ({
NodeProvider: ({ children }: { children: React.ReactNode }) => children,
useNodeContext: vi.fn(() => mockNodeContextValue),
}));
vi.mock("../../components/model-onboarding-state", () => ({
isOnboardingResumable: () => false,
getOnboardingResumeStep: () => null,
getOnboardingState: () => null,
saveOnboardingState: vi.fn(),
clearOnboardingState: vi.fn(),
isOnboardingCompleted: () => false,
markOnboardingCompleted: vi.fn(),
markStepSkipped: vi.fn(),
getOnboardingCompletedAt: () => null,
getSkippedSteps: () => [],
getStepData: () => null,
ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "agent", "first-task"],
}));
vi.mock("../../components/Board", () => ({
Board: ({ tasks, onOpenDetail }: { tasks: Task[]; onOpenDetail: (task: Task) => void }) => (
<div data-testid="board-view">
{tasks.map((task) => (
<button key={task.id} type="button" data-testid={`open-task-${task.id}`} onClick={() => onOpenDetail(task)}>
{task.title}
</button>
))}
</div>
),
}));
vi.mock("../../components/ListView", () => ({
ListView: () => <div data-testid="list-view" />,
}));
vi.mock("../../components/TaskDetailModal", () => ({
TaskDetailModal: () => null,
TaskDetailContent: ({ task }: { task: { id: string; title?: string } }) => (
<div data-testid="task-detail-main-panel-content">
<h2>{task.title ?? task.id}</h2>
</div>
),
}));
vi.mock("../../components/SettingsModal", () => ({
SettingsModal: () => null,
SettingsView: () => <div data-testid="settings-view">Settings</div>,
}));
vi.mock("../../components/GitHubImportModal", () => ({ GitHubImportModal: () => null }));
vi.mock("../../components/PlanningModeModal", () => ({ PlanningModeModal: () => null }));
vi.mock("../../components/AgentsView", () => ({ AgentsView: () => <div data-testid="agents-view">Agents</div> }));
vi.mock("../../components/ResearchView", () => ({ ResearchView: () => <div data-testid="research-view">Research</div> }));
vi.mock("../../components/EvalsView", () => ({ EvalsView: () => <div data-testid="evals-view">Evals</div> }));
vi.mock("../../components/TodoView", () => ({ TodoView: () => <div data-testid="todo-view">Todo</div> }));
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: () => <div /> }));
vi.mock("../../components/ProjectCard", () => ({ ProjectCard: () => <div /> }));
vi.mock("../../components/Sidebar", () => ({ Sidebar: () => <div /> }));
vi.mock("../../components/Header", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../components/Header")>();
return {
...actual,
Header: () => <div><button title="Settings" type="button">Settings</button></div>,
};
});
vi.mock("../../components/MobileNavBar", () => ({ MobileNavBar: () => null }));
vi.mock("../../components/RightDock", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../components/RightDock")>();
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(<App />);
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();
});
});

View File

@@ -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(
<TaskDetailModal
task={makeTask({ id: "FN-300" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
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(
<TaskDetailModal
task={makeTask({ id: "FN-301" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
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(
<TaskDetailModal
task={makeTask({ id: "FN-302" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await waitFor(() => {
expect(document.querySelector(".task-detail-modal--mobile-transition")).toBeInTheDocument();
});
setViewportWidth(DESKTOP_WIDTH);
rerender(
<TaskDetailModal
task={makeTask({ id: "FN-302" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await waitFor(() => {
expect(document.querySelector(".task-detail-modal--mobile-transition")).not.toBeInTheDocument();
});
});
});

View File

@@ -751,7 +751,15 @@ export function MainContent({
}
return (
<PageErrorBoundary>
<div className="task-detail-main-panel">
{/*
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).
*/}
<div className={`task-detail-main-panel${isMobile ? " task-detail-main-panel--mobile-transition" : ""}`}>
<div className="task-detail-main-panel-body">
<TaskDetailContent
task={liveDetailTask}

View File

@@ -3958,3 +3958,38 @@ The panel and its body must be width-bounded (width:100%; min-width:0; max-width
overflow-x: hidden;
overflow-y: auto;
}
/*
FNXC:TaskDetailSwipeBack 2026-07-05-12:30:
FN-7587 — non-interactive predictive-back polish: a short slide/fade enter animation for the
mobile full-panel task detail, layered purely on top of the unchanged FN-7583/FN-7586 dismissal
routing (popstate / fusion:native-back / useNavigationHistory stack). Mobile-only (gated by the
existing `isMobile` prop in MainContent.tsx); desktop never receives this class. A true
finger-tracked interactive predictive-back is not feasible from a Capacitor single-page WebView
on either platform today (WKWebView exposes no interactive-progress callback to JS; Android's
system predictive-back preview is not driveable from the in-page DOM) — see task FN-7587 notes
and the filed interactive-predictive-back follow-up task.
*/
@media (max-width: 768px) {
.task-detail-main-panel--mobile-transition {
animation: task-detail-mobile-slide-fade-in var(--duration-normal) ease-out;
}
}
@keyframes task-detail-mobile-slide-fade-in {
from {
opacity: 0;
transform: translateX(var(--space-xl, 24px));
}
to {
opacity: 1;
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.task-detail-main-panel--mobile-transition {
animation: none;
}
}

View File

@@ -45,6 +45,36 @@ Manual invocation (e.g. after a bare `npx cap sync` outside `build:mobile`):
pnpm --filter @fusion/mobile patch:android-manifest
```
### Mobile task-detail predictive-back transition (FN-7587)
FN-7583 (Android back-gesture parity) and FN-7586 (iOS edge-swipe-back parity) made native
"back" gestures **functionally** dismiss Fusion's mobile task-detail surfaces (board
main-panel, list-mobile, modal, and nested detail) through the dashboard's shared
nav-history invariant (`useNavigationHistory` / `popstate` / `fusion:native-back`). FN-7587
layers a **presentation-only** slide/fade transition on top of that unchanged routing:
- Mobile/native-only — the transition is gated to the mobile viewport (`<= 768px`, matching
the existing `isMobile`/`OVERSIGHT_MENU_MOBILE_BREAKPOINT` convention in the dashboard);
desktop task-detail never receives the animation class.
- Non-interactive — the transition is a short CSS `@keyframes` slide/fade (~200ms) triggered
by mount/prop-state change, not by gesture progress. It does **not** intercept, delay, or
reorder when the `useNavigationHistory` pop / `fusion:native-back` / empty-stack fallback
fires; the animation is purely a CSS class applied to the already-real DOM node.
- Honors `prefers-reduced-motion: reduce` (neutralizes to an instant, transform-free show),
mirroring the dashboard's existing reduced-motion convention (`WorkflowSwitcher.css`,
`TopProgressBar.css`).
- **Interactive predictive-back is not implemented** and is not feasible today from a
Capacitor single-page WebView on either platform: iOS's `allowsBackForwardNavigationGestures`
gesture (used by FN-7586) exposes only a discrete `popstate` on commit, with no
interactive-progress callback reachable from JS; Android's OS-owned predictive-back preview
animates outside the single-Activity WebView and is not driveable from in-page DOM. A
follow-up task is filed to revisit this if/when platform APIs expose gesture-progress
callbacks to JS.
Implementation lives entirely in the dashboard package (`packages/dashboard/app/styles.css`,
`packages/dashboard/app/components/TaskDetailModal.css`, `MainContent.tsx`,
`TaskDetailModal.tsx`) — no mobile-shell-native code changes were required for this task.
### Regression coverage locked by tests
`packages/mobile/src/__tests__/connection-profiles.test.ts`, `native-shell.test.ts`, and `qr-scanner.test.ts` now lock these contracts: