diff --git a/.changeset/fn-9201-mobile-nav-keyboard-lift.md b/.changeset/fn-9201-mobile-nav-keyboard-lift.md
new file mode 100644
index 0000000000..534ad46b0c
--- /dev/null
+++ b/.changeset/fn-9201-mobile-nav-keyboard-lift.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Stop the mobile navigation bar from rising with the on-screen keyboard.
+category: fix
+dev: Clamp computeIcbOffsets, add computeMobileBarKeyboardFlags focus state, useKeyboardFocusPending, and useMobileBarKeyboardState.
diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md
index 06d5fd6b99..d377415503 100644
--- a/docs/dashboard-guide.md
+++ b/docs/dashboard-guide.md
@@ -222,6 +222,8 @@ The active nav-item highlight and the resize-handle hover/focus accent track the
On mobile viewports (`<=768px`), the sidebar is not rendered even when the default-on setting is enabled. The Header's **New Task** action opens the existing full form from any active project view. The bottom `MobileNavBar` provides separate **Tasks** (Board) and **List** destinations, while the Planning column keeps its inline quick-entry composer. Mobile-only More-sheet entries remain available for compact tools such as Git Manager, Terminal, Files, and **Import from GitHub**.
+When a soft keyboard opens, fixed mobile bottom bars never rise with it. The navigation bar slides completely off-screen as soon as a text field gains focus, including landscape phones. While a text field is focused at normal scale, viewport compensation clamps `--icb-bottom-offset` to `0px`, including in hosts without the visual viewport API, so stale viewport measurements cannot lift fixed chrome.
+
On the **All Projects** overview, Fusion renders registered project cards, navigation, filters, and controls as soon as the project list is available. Per-project health metrics hydrate progressively as bounded background requests complete; pending or failed telemetry never replaces the grid with a full loading skeleton. Fusion continues refreshing those metrics in the background and suspends polling while the tab is hidden. It suppresses the mobile bottom nav because task tabs are not active there and clears `--mobile-nav-height`, so the overview does not reserve bottom-bar space. The overview reflows through 320px: headers, filters, stats, and card actions stack or wrap, while long project names and paths truncate rather than overflowing. Dashboard component styles use literal pixel values for media-query breakpoints (including `480px` and `380px`); declaration values within those blocks stay token-based.
@@ -2313,7 +2315,7 @@ Small fixed notification cards (for example the first-task GitHub star prompt) s
Breakpoints: 768px (primary mobile), 1024px (tablet `min-width: 769px and max-width: 1024px`), 640px (compact), 480px (xs). Mobile overrides go in `@media (max-width: 768px)` blocks at the bottom of `styles.css` after base styles.
-**Bottom spacing:** `--mobile-nav-height` (44px) + `env(safe-area-inset-bottom, 0px)` + `--standalone-bottom-gap` (0/8px PWA). All bottom-positioned mobile elements compose those. When the soft keyboard opens, the mobile nav bar stays pinned to page bottom cross-platform; the executor footer keyboard-collapse pin is iOS-only. On Android (`interactive-widget=resizes-content`), the footer keeps its stacked position above the nav bar to avoid overlap after keyboard dismiss.
+**Bottom spacing:** `--mobile-nav-height` (44px) + `env(safe-area-inset-bottom, 0px)` + `--standalone-bottom-gap` (0/8px PWA). All bottom-positioned mobile elements compose those. Fixed mobile bottom bars never rise with the soft keyboard: on keyboard-focusable text focus at scale ≤ 1.01, `--icb-bottom-offset` is clamped to `0px` so an absent or stale viewport baseline cannot lift them, including landscape phones and hosts without `visualViewport`. The mobile nav slides fully off-screen on that focus transition rather than waiting for a settled viewport sample. The executor footer keyboard-collapse pin is iOS-only. On Android (`interactive-widget=resizes-content`), the footer keeps its stacked position above the nav bar to avoid overlap after keyboard dismiss.
**Footer-safe fill layouts:** View wrappers that reserve footer/mobile-nav space (for example `.project-content`) should be flex containers with `min-height: 0` / `min-width: 0`, and child surfaces like `.board` should use `flex: 1 1 auto` plus the same min-size guards. Workflow-mode board wrappers (`.board-workflow-view` → `.board-workflow-columns`) also keep a definite `height: 100%`/`max-height: 100%` chain so the workflow toolbar and columns split the available space on tablet as well as desktop/mobile. This keeps the board/columns stretched between the header and fixed bottom bars across desktop, tablet, and mobile while allowing internal scroll regions to own overflow.
diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx
index 8260273216..fbae8e209e 100644
--- a/packages/dashboard/app/App.tsx
+++ b/packages/dashboard/app/App.tsx
@@ -50,6 +50,7 @@ import { useDeepLink } from "./hooks/useDeepLink";
import { useFavorites } from "./hooks/useFavorites";
import { useAuthOnboarding } from "./hooks/useAuthOnboarding";
import { useMobileKeyboard } from "./hooks/useMobileKeyboard";
+import { useKeyboardFocusPending } from "./hooks/useKeyboardFocusPending";
import { isIOS, useMobileKeyboardViewportLock, useMobileViewportRestoreReset } from "./hooks/useMobileScrollLock";
import { computeMobileBarKeyboardFlags } from "./utils/mobileBarKeyboardFlags";
import { recordActivity } from "./utils/activity-trace";
@@ -197,6 +198,35 @@ function prefetchLazyViews() {
registerBundledPluginViews();
+/*
+FNXC:ViewportChrome 2026-08-23-18:14:
+App-level keyboard state follows Fusion's viewport-mode classification rather than useMobileKeyboard's <=768px heuristic. Landscape phones render the nav in mobile mode; this production seam keeps that path executable in tests.
+*/
+export function useMobileBarKeyboardState({
+ isMobile,
+ anyModalOpen,
+ overlayOpen,
+}: {
+ isMobile: boolean;
+ anyModalOpen: boolean;
+ overlayOpen: boolean;
+}) {
+ const { keyboardOpen } = useMobileKeyboard({ enabled: isMobile, allowNonMobileViewport: isMobile });
+ const keyboardFocusPending = useKeyboardFocusPending(isMobile) || false;
+ return {
+ keyboardOpen,
+ keyboardFocusPending,
+ ...computeMobileBarKeyboardFlags({
+ isMobile,
+ keyboardOpen,
+ keyboardFocusPending,
+ anyModalOpen,
+ overlayOpen,
+ isIOS: isIOS(),
+ }),
+ };
+}
+
export function shouldOpenBoardTaskInDock(openTasksInRightSidebar: boolean, rightDockActive: boolean, initialTab?: DetailTaskTab): boolean {
return !initialTab && openTasksInRightSidebar && rightDockActive;
}
@@ -754,7 +784,11 @@ function AppInner() {
setQuickChatOpen(true);
}, []);
- const { keyboardOpen } = useMobileKeyboard({ enabled: isMobile });
+ const { footerHidden, navKeyboardOpen, footerKeyboardOpen } = useMobileBarKeyboardState({
+ isMobile,
+ anyModalOpen: modalManager.anyModalOpen,
+ overlayOpen: isMobile && quickChatOpen,
+ });
// Keyboard visibility controls both MobileNavBar rendering and whether
// the project content reserves bottom padding for the mobile nav bar.
// When a modal is open, modal-local inputs can trigger the keyboard without
@@ -772,13 +806,6 @@ function AppInner() {
// `footerKeyboardOpen` (the footer `bottom: 0` collapse class) stays
// iOS-only: it only matters when the footer is still rendered (e.g. over
// a modal), where Android's resizes-content already stacks it correctly.
- const { footerHidden, navKeyboardOpen, footerKeyboardOpen } = computeMobileBarKeyboardFlags({
- isMobile,
- keyboardOpen,
- anyModalOpen: modalManager.anyModalOpen,
- overlayOpen: isMobile && quickChatOpen,
- isIOS: isIOS(),
- });
const mobileKeyboardOpen = footerHidden;
const mobileNavKeyboardOpen = navKeyboardOpen;
// App-level scroll lock for inline editing (TaskCard inline edit, etc.):
diff --git a/packages/dashboard/app/__tests__/App.mobile-nav-keyboard-landscape.integration.test.tsx b/packages/dashboard/app/__tests__/App.mobile-nav-keyboard-landscape.integration.test.tsx
new file mode 100644
index 0000000000..1cac056e69
--- /dev/null
+++ b/packages/dashboard/app/__tests__/App.mobile-nav-keyboard-landscape.integration.test.tsx
@@ -0,0 +1,91 @@
+import { act, render, screen } from "@testing-library/react";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { useMobileBarKeyboardState } from "../App";
+import { MobileNavBar } from "../components/MobileNavBar";
+import { _resetInitialViewportHeight } from "../hooks/useMobileKeyboard";
+
+function installViewport(width: number, height: number) {
+ const target = new EventTarget();
+ const viewport = {
+ width,
+ height,
+ offsetTop: 0,
+ offsetLeft: 0,
+ scale: 1,
+ addEventListener: target.addEventListener.bind(target),
+ removeEventListener: target.removeEventListener.bind(target),
+ dispatchEvent: target.dispatchEvent.bind(target),
+ };
+ Object.defineProperty(window, "visualViewport", { configurable: true, value: viewport });
+ return viewport;
+}
+
+function MobileNavKeyboardHarness({ isMobile }: { isMobile: boolean }) {
+ const state = useMobileBarKeyboardState({ isMobile, anyModalOpen: false, overlayOpen: false });
+ return <>
+
+
+
+ >;
+}
+
+describe("App landscape mobile keyboard seam", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ _resetInitialViewportHeight();
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 932 });
+ Object.defineProperty(window, "innerHeight", { configurable: true, value: 430 });
+ Object.defineProperty(window, "screen", { configurable: true, value: { width: 932, height: 430 } });
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ document.body.replaceChildren();
+ });
+
+ it("renders the nav off-screen immediately on focus and after a settled landscape sample", () => {
+ const viewport = installViewport(932, 430);
+ const textarea = document.createElement("textarea");
+ document.body.append(textarea);
+ const { container } = render();
+ const nav = container.querySelector(".mobile-nav-bar");
+ expect(nav).not.toBeNull();
+ expect(nav).not.toHaveClass("mobile-nav-bar--keyboard-open");
+
+ act(() => { textarea.focus(); textarea.dispatchEvent(new FocusEvent("focusin", { bubbles: true })); });
+ expect(nav).toHaveClass("mobile-nav-bar--keyboard-open");
+ expect(screen.getByTestId("footer-hidden")).toHaveTextContent("false");
+
+ viewport.height = 220;
+ act(() => { viewport.dispatchEvent(new Event("resize")); vi.advanceTimersByTime(1_000); });
+ expect(screen.getByTestId("keyboard-open")).toHaveTextContent("true");
+ expect(nav).toHaveClass("mobile-nav-bar--keyboard-open");
+
+ viewport.height = 430;
+ act(() => { textarea.blur(); textarea.dispatchEvent(new FocusEvent("focusout", { bubbles: true })); viewport.dispatchEvent(new Event("resize")); vi.advanceTimersByTime(1_000); });
+ expect(nav).not.toHaveClass("mobile-nav-bar--keyboard-open");
+ expect(screen.getByTestId("footer-hidden")).toHaveTextContent("false");
+ expect(document.body.style.position).not.toBe("fixed");
+ });
+
+ it("never fabricates landscape keyboard state for a non-mobile host", () => {
+ const viewport = installViewport(1280, 430);
+ const textarea = document.createElement("textarea");
+ document.body.append(textarea);
+ const { container } = render();
+ act(() => { textarea.focus(); textarea.dispatchEvent(new FocusEvent("focusin", { bubbles: true })); viewport.height = 220; viewport.dispatchEvent(new Event("resize")); vi.advanceTimersByTime(1_000); });
+ expect(screen.getByTestId("keyboard-open")).toHaveTextContent("false");
+ expect(container.querySelector(".mobile-nav-bar")).not.toHaveClass("mobile-nav-bar--keyboard-open");
+ });
+
+ it("keeps App's only keyboard hook call inside the exported production seam", () => {
+ const appSource = readFileSync(resolve(__dirname, "../App.tsx"), "utf8");
+ expect(appSource.match(/useMobileKeyboard\(/g)).toHaveLength(1);
+ const seamStart = appSource.indexOf("export function useMobileBarKeyboardState");
+ const hookCall = appSource.indexOf("useMobileKeyboard(");
+ const seamEnd = appSource.indexOf("export function shouldOpenBoardTaskInDock");
+ expect(hookCall).toBeGreaterThan(seamStart);
+ expect(hookCall).toBeLessThan(seamEnd);
+ });
+});
diff --git a/packages/dashboard/app/__tests__/icb-inline-script.runtime.test.ts b/packages/dashboard/app/__tests__/icb-inline-script.runtime.test.ts
new file mode 100644
index 0000000000..22dd0c87ef
--- /dev/null
+++ b/packages/dashboard/app/__tests__/icb-inline-script.runtime.test.ts
@@ -0,0 +1,74 @@
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const html = readFileSync(resolve(__dirname, "../index.html"), "utf8");
+const scripts = [...html.matchAll(/