fix(dashboard): stop the mobile Settings footer clipping the update notice

The mobile footer is a single nowrap scrolling rail, so an update banner
joining it clipped mid-sentence and pushed Import/Export/Reset/Close
off-screen. Render the update-check result in its own full-width row above
the rail on mobile; desktop/tablet keep it inline next to the version button.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-25 11:21:11 -07:00
parent a0496c175c
commit b0fdef433d
4 changed files with 94 additions and 15 deletions

View File

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

View File

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

View File

@@ -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 ? (
<span
aria-live="polite"
className={`settings-update-result ${
updateCheckResult.error
? "settings-update-result--error"
: updateCheckResult.updateAvailable
? "settings-update-result--available"
: "settings-update-result--up-to-date"
}`}
>
{renderUpdateCheckResultContent()}
</span>
) : null;
// Load auth status when the authentication section is active
const loadAuthStatus = useCallback(async () => {
try {
@@ -4916,6 +4940,9 @@ export function SettingsModal({
</div>
</div>
)}
{viewportMode === "mobile" && updateCheckResultNode && (
<div className="settings-modal-footer-update-row">{updateCheckResultNode}</div>
)}
<div className="modal-actions">
<div className="settings-modal-footer-version">
<a
@@ -4952,20 +4979,7 @@ export function SettingsModal({
<RefreshCw size={12} className={updateCheckLoading ? "spinning" : undefined} />
</button>
)}
{updateCheckResult && (
<span
aria-live="polite"
className={`settings-update-result ${
updateCheckResult.error
? "settings-update-result--error"
: updateCheckResult.updateAvailable
? "settings-update-result--available"
: "settings-update-result--up-to-date"
}`}
>
{renderUpdateCheckResultContent()}
</span>
)}
{viewportMode !== "mobile" && updateCheckResultNode}
</div>
</div>
<div className="modal-actions-left">

View File

@@ -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(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
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(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
@@ -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;");