diff --git a/.changeset/settings-footer-update-banner-mobile.md b/.changeset/settings-footer-update-banner-mobile.md new file mode 100644 index 0000000000..ecb8044f44 --- /dev/null +++ b/.changeset/settings-footer-update-banner-mobile.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix the Settings footer update notice and buttons being cut off on mobile. +category: fix +dev: On mobile the update-check result renders in a new `.settings-modal-footer-update-row` above the nowrap `.modal-actions` rail; desktop/tablet keep it inline next to the version button. diff --git a/packages/dashboard/app/components/SettingsModal.css b/packages/dashboard/app/components/SettingsModal.css index b9f1885333..8b37617bca 100644 --- a/packages/dashboard/app/components/SettingsModal.css +++ b/packages/dashboard/app/components/SettingsModal.css @@ -402,6 +402,32 @@ Fix the invariant for BOTH presentations (standalone modal + embedded SettingsVi white-space: normal; } + /* + FNXC:SettingsUpdate 2026-07-25-19:40: + Mobile-only row that carries the update-check result above the footer rail (rendered by SettingsModal.tsx when + viewportMode === "mobile"). The rail below is a deliberate nowrap scroller; the banner is not, so it wraps and + centers here at full width and can no longer clip itself or shove Import/Export/Reset/Close out of view. + It owns the footer's top divider and the adjacent rail drops its own so the two rows read as one footer block. + */ + .settings-modal .settings-modal-footer-update-row { + display: flex; + flex-shrink: 0; + justify-content: center; + text-align: center; + padding: var(--space-sm) var(--modal-padding) 0; + border-top: 1px solid var(--border); + background: color-mix(in srgb, var(--text) 5%, transparent); + } + + .settings-modal .settings-modal-footer-update-row + .modal-actions { + border-top: none; + } + + .settings-modal .settings-modal-footer-update-row .settings-update-result { + justify-content: center; + white-space: normal; + } + .settings-modal .settings-footer-help-btn { align-items: center; flex-shrink: 0; diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index ebae8be6f8..ca09101351 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -2160,6 +2160,30 @@ export function SettingsModal({ return t("settings.general.upToDate", "You're up to date โœ“"); }, [handleInstallUpdate, handleRestart, restartError, restartLoading, restartScheduled, restartSupported, t, updateCheckResult, updateInstallLoading, updateInstallResult]); + /* + FNXC:SettingsUpdate 2026-07-25-19:40: + The update-check result ("vX available ยท Learn more" plus the Update now / Restart controls) is one node rendered + in two places: inline next to the version button on desktop/tablet, and on its OWN full-width row above the footer + rail on mobile. The mobile footer is a single nowrap horizontally-scrolling rail (FN-7752); once an update banner + joined that rail its intrinsic width exceeded the viewport, so the banner itself was clipped mid-sentence and + Import/Export/Reset/Close were pushed off-screen behind a scroll affordance operators do not see. Giving the banner + its own row keeps the rail to the controls it was sized for, and the banner wraps normally instead of clipping. + */ + const updateCheckResultNode = updateCheckResult ? ( + + {renderUpdateCheckResultContent()} + + ) : null; + // Load auth status when the authentication section is active const loadAuthStatus = useCallback(async () => { try { @@ -4916,6 +4940,9 @@ export function SettingsModal({ )} + {viewportMode === "mobile" && updateCheckResultNode && ( +
{updateCheckResultNode}
+ )}
)} - {updateCheckResult && ( - - {renderUpdateCheckResultContent()} - - )} + {viewportMode !== "mobile" && updateCheckResultNode}
diff --git a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx index de4b88544e..1ed7a9b85a 100644 --- a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx @@ -397,12 +397,40 @@ describe("SettingsModal mobile adaptations", () => { await user.click(within(modalActions as HTMLElement).getByRole("button", { name: "Check for updates" })); const updateNow = await findByRole("button", { name: "Update now" }); - expect((modalActions as HTMLElement).contains(updateNow)).toBe(true); + + /* + The mobile footer rail is a single nowrap horizontal scroller, so the update banner must live in its own + full-width row directly above it โ€” inside the rail it clipped itself and pushed Import/Export/Reset/Close + off-screen. Assert both halves of that invariant: banner outside the rail, banner present in the footer row. + */ + const updateRow = container.querySelector(".settings-modal-footer-update-row"); + expect(updateRow).toBeTruthy(); + expect((updateRow as HTMLElement).contains(updateNow)).toBe(true); + expect((modalActions as HTMLElement).contains(updateNow)).toBe(false); + expect(modalActions?.querySelector(".settings-update-result")).toBeNull(); + expect(updateRow?.nextElementSibling).toBe(modalActions); await user.click(updateNow); expect(await findByText("Updated to v2.0.0 โ€” restart Fusion to apply")).toBeTruthy(); }); + it("keeps the update result inline in the footer rail on desktop", async () => { + mockSettingsViewport(false); + const user = userEvent.setup(); + const { container, findByRole } = render(); + await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); + + const modalActions = container.querySelector(".modal-actions"); + expect(modalActions).toBeTruthy(); + + await user.click(within(modalActions as HTMLElement).getByRole("button", { name: "Check for updates" })); + const updateNow = await findByRole("button", { name: "Update now" }); + + expect(container.querySelector(".settings-modal-footer-update-row")).toBeNull(); + expect((modalActions as HTMLElement).contains(updateNow)).toBe(true); + expect(container.querySelector(".settings-update-check .settings-update-result")).toBeTruthy(); + }); + it("preserves the mobile section picker accessible name without rendering a visible label", async () => { mockSettingsViewport(true); const { container, getByLabelText, queryByText } = render(); @@ -811,6 +839,10 @@ describe("SettingsModal mobile adaptations", () => { expectMobileRule(css, ".settings-modal .settings-modal-footer-version", "margin-right: 0;"); expectMobileRule(css, ".settings-modal .settings-update-check", "align-items: center;"); expectMobileRule(css, ".settings-modal .settings-update-check", "flex-wrap: wrap;"); + // The update banner owns a full-width row above the nowrap footer rail, and the rail drops its duplicate divider. + expectMobileRule(css, ".settings-modal .settings-modal-footer-update-row", "border-top: 1px solid var(--border);"); + expectMobileRule(css, ".settings-modal .settings-modal-footer-update-row", "justify-content: center;"); + expectMobileRule(css, ".settings-modal .settings-modal-footer-update-row + .modal-actions", "border-top: none;"); expectMobileRule(css, ".settings-modal .settings-version-check-btn", "line-height: 1;"); expectMobileRule(css, ".settings-modal .settings-version-check-btn", "white-space: nowrap;"); expectMobileRule(css, ".settings-modal .settings-modal-version", "display: inline-flex;");