From 8a7507ad36ac118f07224c2329a051a868bb2811 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 5 Jul 2026 12:38:01 -0700 Subject: [PATCH] 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) --- .../app/components/TaskDetailModal.css | 33 ++ .../app/components/TaskDetailModal.tsx | 31 +- ...ail.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(-) create mode 100644 packages/dashboard/app/components/__tests__/TaskDetail.mobile-transition.board-panel.test.tsx create mode 100644 packages/dashboard/app/components/__tests__/TaskDetail.mobile-transition.test.tsx diff --git a/packages/dashboard/app/components/TaskDetailModal.css b/packages/dashboard/app/components/TaskDetailModal.css index d3300f17c2..c4418e0345 100644 --- a/packages/dashboard/app/components/TaskDetailModal.css +++ b/packages/dashboard/app/components/TaskDetailModal.css @@ -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); } diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 294362fe83..010c127e3b 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -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 (
-
+
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(), { + 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 }) => ( +
+ {tasks.map((task) => ( + + ))} +
+ ), +})); + +vi.mock("../../components/ListView", () => ({ + ListView: () =>
, +})); + +vi.mock("../../components/TaskDetailModal", () => ({ + TaskDetailModal: () => null, + TaskDetailContent: ({ task }: { task: { id: string; title?: string } }) => ( +
+

{task.title ?? task.id}

+
+ ), +})); + +vi.mock("../../components/SettingsModal", () => ({ + SettingsModal: () => null, + SettingsView: () =>
Settings
, +})); + +vi.mock("../../components/GitHubImportModal", () => ({ GitHubImportModal: () => null })); +vi.mock("../../components/PlanningModeModal", () => ({ PlanningModeModal: () => null })); +vi.mock("../../components/AgentsView", () => ({ AgentsView: () =>
Agents
})); +vi.mock("../../components/ResearchView", () => ({ ResearchView: () =>
Research
})); +vi.mock("../../components/EvalsView", () => ({ EvalsView: () =>
Evals
})); +vi.mock("../../components/TodoView", () => ({ TodoView: () =>
Todo
})); +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). + */} +