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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-07 07:54:06 -07:00
parent ca7c987ab5
commit ebf8f87fdb
4 changed files with 165 additions and 0 deletions

View File

@@ -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.

View File

@@ -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 === */

View File

@@ -3612,6 +3612,23 @@ export function SettingsModal({
&times;
</button>
)}
{/*
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" && (
<button
className="modal-close settings-embedded-mobile-close"
onClick={onClose}
aria-label={t("actions.close", "Close")}
>
&times;
</button>
)}
</div>
{loading ? (
<div className="settings-empty-state settings-loading"><LoadingSpinner label={t("settings.loading", "Loading…")} /></div>

View File

@@ -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<typeof import("../../api")>(), {
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<React.ComponentProps<typeof SettingsModal>> = {}) {
return render(<SettingsModal onClose={() => {}} 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);
});
});