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:
7
.changeset/settings-footer-update-banner-mobile.md
Normal file
7
.changeset/settings-footer-update-banner-mobile.md
Normal 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.
|
||||||
@@ -402,6 +402,32 @@ Fix the invariant for BOTH presentations (standalone modal + embedded SettingsVi
|
|||||||
white-space: normal;
|
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 {
|
.settings-modal .settings-footer-help-btn {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|||||||
@@ -2160,6 +2160,30 @@ export function SettingsModal({
|
|||||||
return t("settings.general.upToDate", "You're up to date ✓");
|
return t("settings.general.upToDate", "You're up to date ✓");
|
||||||
}, [handleInstallUpdate, handleRestart, restartError, restartLoading, restartScheduled, restartSupported, t, updateCheckResult, updateInstallLoading, updateInstallResult]);
|
}, [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
|
// Load auth status when the authentication section is active
|
||||||
const loadAuthStatus = useCallback(async () => {
|
const loadAuthStatus = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -4916,6 +4940,9 @@ export function SettingsModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{viewportMode === "mobile" && updateCheckResultNode && (
|
||||||
|
<div className="settings-modal-footer-update-row">{updateCheckResultNode}</div>
|
||||||
|
)}
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
<div className="settings-modal-footer-version">
|
<div className="settings-modal-footer-version">
|
||||||
<a
|
<a
|
||||||
@@ -4952,20 +4979,7 @@ export function SettingsModal({
|
|||||||
<RefreshCw size={12} className={updateCheckLoading ? "spinning" : undefined} />
|
<RefreshCw size={12} className={updateCheckLoading ? "spinning" : undefined} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{updateCheckResult && (
|
{viewportMode !== "mobile" && updateCheckResultNode}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-actions-left">
|
<div className="modal-actions-left">
|
||||||
|
|||||||
@@ -397,12 +397,40 @@ describe("SettingsModal mobile adaptations", () => {
|
|||||||
|
|
||||||
await user.click(within(modalActions as HTMLElement).getByRole("button", { name: "Check for updates" }));
|
await user.click(within(modalActions as HTMLElement).getByRole("button", { name: "Check for updates" }));
|
||||||
const updateNow = await findByRole("button", { name: "Update now" });
|
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);
|
await user.click(updateNow);
|
||||||
expect(await findByText("Updated to v2.0.0 — restart Fusion to apply")).toBeTruthy();
|
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 () => {
|
it("preserves the mobile section picker accessible name without rendering a visible label", async () => {
|
||||||
mockSettingsViewport(true);
|
mockSettingsViewport(true);
|
||||||
const { container, getByLabelText, queryByText } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
|
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-modal-footer-version", "margin-right: 0;");
|
||||||
expectMobileRule(css, ".settings-modal .settings-update-check", "align-items: center;");
|
expectMobileRule(css, ".settings-modal .settings-update-check", "align-items: center;");
|
||||||
expectMobileRule(css, ".settings-modal .settings-update-check", "flex-wrap: wrap;");
|
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", "line-height: 1;");
|
||||||
expectMobileRule(css, ".settings-modal .settings-version-check-btn", "white-space: nowrap;");
|
expectMobileRule(css, ".settings-modal .settings-version-check-btn", "white-space: nowrap;");
|
||||||
expectMobileRule(css, ".settings-modal .settings-modal-version", "display: inline-flex;");
|
expectMobileRule(css, ".settings-modal .settings-modal-version", "display: inline-flex;");
|
||||||
|
|||||||
Reference in New Issue
Block a user