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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user