From ebf8f87fdb724a3a33660f191f0825a2dde6a8d5 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 7 Jul 2026 07:54:06 -0700 Subject: [PATCH] FN-7627: add mobile close button to embedded Settings screen Adds a close affordance to the embedded Settings header on mobile, since only a bottom nav bar (no sidebar) is available to exit there. - Render a mobile-only `modal-close` button in the embedded Settings header when `isEmbedded && viewportMode === "mobile"`, wired to the existing `onClose` prop. - Leave desktop/tablet embedded and the standalone modal presentation unchanged. - Add regression tests covering the new mobile close button. - Add a patch changeset documenting the fix. Files changed: .changeset/fn-7627-mobile-settings-close.md | 7 ++ .../dashboard/app/components/SettingsModal.css | 16 +++ .../dashboard/app/components/SettingsModal.tsx | 17 +++ .../__tests__/SettingsModal.mobileClose.test.tsx | 125 +++++++++++++++++++++ 4 files changed, 165 insertions(+) Fusion-Task-Id: FN-7627 Fusion-Task-Lineage: b39c7172-bce3-4fb2-9095-58ae376c43ef Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7627-mobile-settings-close.md | 7 + .../app/components/SettingsModal.css | 16 +++ .../app/components/SettingsModal.tsx | 17 +++ .../SettingsModal.mobileClose.test.tsx | 125 ++++++++++++++++++ 4 files changed, 165 insertions(+) create mode 100644 .changeset/fn-7627-mobile-settings-close.md create mode 100644 packages/dashboard/app/components/__tests__/SettingsModal.mobileClose.test.tsx diff --git a/.changeset/fn-7627-mobile-settings-close.md b/.changeset/fn-7627-mobile-settings-close.md new file mode 100644 index 0000000000..dd0de95228 --- /dev/null +++ b/.changeset/fn-7627-mobile-settings-close.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Add a close button to the Settings screen on mobile. +category: fix +dev: The embedded Settings header now renders a mobile-only `modal-close` control gated on `isEmbedded && viewportMode === "mobile"`, wired to the existing `onClose` prop (navigates back to the board and refreshes app settings). Desktop/tablet embedded and the standalone modal presentation are unchanged. diff --git a/packages/dashboard/app/components/SettingsModal.css b/packages/dashboard/app/components/SettingsModal.css index 21c5122a70..403836b1ba 100644 --- a/packages/dashboard/app/components/SettingsModal.css +++ b/packages/dashboard/app/components/SettingsModal.css @@ -235,6 +235,22 @@ The embedded title reads like other embedded-view titles (Planning modal-header- width: 100%; height: 100%; } + + /* + FNXC:Settings 2026-07-07-00:00: + Mobile embedded Settings has no left sidebar to exit through (only the bottom MobileNavBar), so the header needs + an explicit close affordance. Reuse the shared .modal-close sizing/hover and pin it to the header's trailing + edge, after the Star/Discord actions, without displacing them. Desktop/tablet embedded and the standalone modal + presentation are untouched by this rule (scoped to .settings-modal--embedded .modal-header--embedded). + */ + .settings-modal--embedded .modal-header--embedded .settings-embedded-mobile-close { + --settings-embedded-mobile-close-touch-target: calc(var(--space-lg) + var(--space-lg) + var(--space-xs)); + + flex-shrink: 0; + margin-left: var(--space-xs); + min-height: var(--settings-embedded-mobile-close-touch-target); + min-width: var(--settings-embedded-mobile-close-touch-target); + } } /* === Settings Layout === */ diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index df4fe43bb0..da08553fea 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -3612,6 +3612,23 @@ export function SettingsModal({ × )} + {/* + FNXC:Settings 2026-07-07-00:00: + Mobile embedded Settings (taskView === "settings", presentation="embedded") has no left sidebar to exit + through — only the bottom MobileNavBar — so the header needs an explicit close affordance calling the + existing onClose prop (wired to closeSettingsView: modalManager.closeSettings() + back to board + refresh + app settings). Desktop/tablet embedded still exit via the sidebar (no button here), and the standalone + modal presentation keeps its own `!isEmbedded` `modal-close` button above, untouched and byte-identical. + */} + {isEmbedded && viewportMode === "mobile" && ( + + )} {loading ? (
diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.mobileClose.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.mobileClose.test.tsx new file mode 100644 index 0000000000..b325094b0a --- /dev/null +++ b/packages/dashboard/app/components/__tests__/SettingsModal.mobileClose.test.tsx @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import { SettingsModal } from "../SettingsModal"; + +/* +FNXC:Settings 2026-07-07-00:00: +FN-7627 Surface Enumeration coverage: mobile embedded Settings has no left sidebar to exit through, so the +embedded header renders a mobile-only close button (isEmbedded && viewportMode === "mobile") calling onClose. +This suite asserts the invariant across every enumerated surface: embedded+mobile (renders, calls onClose, works +with/without a selected projectId), embedded+desktop (no button), embedded+tablet (no button), and the standalone +modal presentation (its existing `!isEmbedded` modal-close `×` stays the only close control, not duplicated). +*/ + +const mockFetchSettings = vi.fn(); +const mockFetchSettingsByScope = vi.fn(); +const mockFetchAuthStatus = vi.fn(); +const mockFetchModels = vi.fn(); +const mockFetchCustomProviders = vi.fn(); +const mockFetchMemoryFiles = vi.fn(); +const mockFetchGlobalConcurrency = vi.fn(); +const mockFetchDashboardHealth = vi.fn(); +const mockUseViewportMode = vi.fn(); + +vi.mock("../../api", async (importOriginal) => { + const { createDashboardApiMock } = await import("../../test/mockApi"); + return createDashboardApiMock(() => importOriginal(), { + fetchSettings: (...args: unknown[]) => mockFetchSettings(...args), + fetchSettingsByScope: (...args: unknown[]) => mockFetchSettingsByScope(...args), + fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args), + fetchModels: (...args: unknown[]) => mockFetchModels(...args), + fetchCustomProviders: (...args: unknown[]) => mockFetchCustomProviders(...args), + fetchMemoryFiles: (...args: unknown[]) => mockFetchMemoryFiles(...args), + fetchGlobalConcurrency: (...args: unknown[]) => mockFetchGlobalConcurrency(...args), + fetchDashboardHealth: (...args: unknown[]) => mockFetchDashboardHealth(...args), + }); +}); + +vi.mock("../../hooks/useViewportMode", () => ({ + MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", + useViewportMode: (...args: unknown[]) => mockUseViewportMode(...args), + getViewportMode: (...args: unknown[]) => mockUseViewportMode(...args), + isMobileViewport: () => mockUseViewportMode() === "mobile", +})); +vi.mock("../../hooks/useMobileKeyboard", () => ({ + useMobileKeyboard: () => ({ keyboardOpen: false, keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0 }), +})); +vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: vi.fn().mockResolvedValue(true) }) })); + +const defaultSettings = { + maxConcurrent: 2, + maxWorktrees: 4, + pollIntervalMs: 15000, + autoMerge: true, + worktrunk: { enabled: false, binaryPath: "", onFailure: "fail" }, +}; + +function renderModal(props: Partial> = {}) { + return render( {}} addToast={() => {}} initialSection="general" {...props} />); +} + +describe("SettingsModal mobile embedded close button (FN-7627)", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetchSettings.mockResolvedValue(defaultSettings); + mockFetchSettingsByScope.mockResolvedValue({ global: defaultSettings, project: {} }); + mockFetchAuthStatus.mockResolvedValue({ providers: [] }); + mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }); + mockFetchCustomProviders.mockResolvedValue({ providers: [] }); + mockFetchMemoryFiles.mockResolvedValue({ files: [] }); + mockFetchGlobalConcurrency.mockResolvedValue({ maxConcurrentRuns: 4 }); + mockFetchDashboardHealth.mockResolvedValue({}); + mockUseViewportMode.mockReturnValue("desktop"); + }); + + it("renders a close button in embedded+mobile with an accessible name and calls onClose exactly once", async () => { + mockUseViewportMode.mockReturnValue("mobile"); + const onClose = vi.fn(); + renderModal({ presentation: "embedded", projectId: "proj-1", onClose }); + await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); + + const closeButton = screen.getByRole("button", { name: "Close" }); + expect(closeButton).toBeInTheDocument(); + fireEvent.click(closeButton); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("renders no header close button in embedded+desktop", async () => { + mockUseViewportMode.mockReturnValue("desktop"); + renderModal({ presentation: "embedded", projectId: "proj-1" }); + await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); + + expect(screen.queryByRole("button", { name: "Close" })).not.toBeInTheDocument(); + }); + + it("renders no header close button in embedded+tablet (no leftover/duplicate affordance)", async () => { + mockUseViewportMode.mockReturnValue("tablet"); + renderModal({ presentation: "embedded", projectId: "proj-1" }); + await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); + + expect(screen.queryByRole("button", { name: "Close" })).not.toBeInTheDocument(); + }); + + it("renders exactly one modal-close button in the standalone modal presentation and does not add the mobile-embedded control", async () => { + mockUseViewportMode.mockReturnValue("mobile"); + const { container } = renderModal({ presentation: "modal" }); + await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); + + const closeButtons = screen.getAllByRole("button", { name: "Close" }); + expect(closeButtons).toHaveLength(1); + expect(container.querySelectorAll(".modal-close")).toHaveLength(1); + expect(container.querySelector(".settings-embedded-mobile-close")).toBeNull(); + }); + + it("still renders and calls onClose in embedded+mobile when opened without a selected projectId (overview entry)", async () => { + mockUseViewportMode.mockReturnValue("mobile"); + const onClose = vi.fn(); + renderModal({ presentation: "embedded", projectId: undefined, onClose }); + await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); + + const closeButton = screen.getByRole("button", { name: "Close" }); + expect(closeButton).toBeInTheDocument(); + fireEvent.click(closeButton); + expect(onClose).toHaveBeenCalledTimes(1); + }); +});