FN-9201: Prevent mobile nav lift when the iOS keyboard opens

Keep mobile bottom chrome pinned or hidden during keyboard focus transitions, including landscape phone layouts.

- track keyboard-focus intent before visual viewport metrics settle
- clamp focused iOS viewport offsets so fixed bars remain behind the keyboard
- cover runtime bootstrap, landscape integration, nav, focus, and offset behavior
- document the mobile keyboard layout contract and add a patch changeset

Files changed:
 .changeset/fn-9201-mobile-nav-keyboard-lift.md     |  7 ++
 docs/dashboard-guide.md                            |  4 +-
 packages/dashboard/app/App.tsx                     | 43 ++++++++--
 ...ile-nav-keyboard-landscape.integration.test.tsx | 91 ++++++++++++++++++++
 .../__tests__/icb-inline-script.runtime.test.ts    | 74 ++++++++++++++++
 .../mobile-bottom-bars-keyboard-layout.test.ts     | 15 +++-
 .../viewport-compensation-keyboard.test.ts         |  5 +-
 .../__tests__/MobileNavBar.keyboard-lift.test.tsx  | 65 ++++++++++++++
 .../__tests__/useKeyboardFocusPending.test.tsx     | 99 ++++++++++++++++++++++
 .../dashboard/app/hooks/useKeyboardFocusPending.ts | 43 ++++++++++
 packages/dashboard/app/index.html                  | 12 ++-
 .../utils/__tests__/mobileBarKeyboardFlags.test.ts | 22 +++++
 .../app/utils/__tests__/viewportOffset.test.ts     | 32 +++++++
 .../dashboard/app/utils/mobileBarKeyboardFlags.ts  | 10 ++-
 packages/dashboard/app/utils/viewportOffset.ts     |  8 ++
 15 files changed, 510 insertions(+), 20 deletions(-)

Fusion-Task-Id: FN-9201

Fusion-Task-Lineage: 4b3b01d3-8968-432b-9537-a63ee6bbde31

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-23 11:40:06 -07:00
parent 29f9edf153
commit 3d37cfa7db
15 changed files with 510 additions and 20 deletions

View File

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

View File

@@ -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.
<!-- FNXC:DashboardResponsiveDocs 2026-07-25-22:57: Project overview has no task destinations, so the mobile navigation and its published height must be absent rather than leaving dead bottom space. Document the 320px responsive and token conventions alongside the operator-visible behavior. -->
<!-- FNXC:ProjectOverviewHealthHydration 2026-08-01-15:40: Registered projects are the navigation-critical content of the all-projects view, so optional per-project health telemetry hydrates progressively rather than delaying cards and their controls. -->
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.

View File

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

View File

@@ -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 <>
<output data-testid="keyboard-open">{String(state.keyboardOpen)}</output>
<output data-testid="footer-hidden">{String(state.footerHidden)}</output>
<MobileNavBar view="board" onChangeView={vi.fn()} footerVisible={!state.footerHidden} keyboardOpen={state.navKeyboardOpen} />
</>;
}
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(<MobileNavKeyboardHarness isMobile />);
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(<MobileNavKeyboardHarness isMobile={false} />);
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);
});
});

View File

@@ -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(/<script>([\s\S]*?)<\/script>/g)]
.map((match) => match[1])
.filter((source) => source.includes("--icb-bottom-offset") && source.includes("viewportOffset.ts"));
if (scripts.length !== 1) throw new Error(`Expected one ICB script, found ${scripts.length}`);
const source = scripts[0];
function setWindowNumber(name: "innerWidth" | "innerHeight", value: number) {
Object.defineProperty(window, name, { configurable: true, value });
}
function installViewport(height: number, scale = 1) {
const target = new EventTarget();
const viewport = {
width: 390, height, offsetTop: 0, offsetLeft: 0, scale,
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 runScript() { new Function(source)(); }
describe("inline ICB compensation", () => {
beforeEach(() => {
vi.useFakeTimers();
document.documentElement.style.removeProperty("--icb-bottom-offset");
document.documentElement.style.removeProperty("--icb-right-offset");
setWindowNumber("innerWidth", 390); setWindowNumber("innerHeight", 844);
});
afterEach(() => { vi.useRealTimers(); document.body.replaceChildren(); });
it("does not lift bars for a focused null-baseline viewport", () => {
installViewport(508); const field = document.createElement("textarea"); document.body.append(field); field.focus();
runScript();
expect(document.documentElement.style.getPropertyValue("--icb-bottom-offset")).toBe("0px");
});
it("does not retain a stale baseline after focus", () => {
const viewport = installViewport(754); runScript();
const field = document.createElement("textarea"); document.body.append(field); field.focus();
(viewport as { height: number }).height = 508;
viewport.dispatchEvent(new Event("resize"));
expect(document.documentElement.style.getPropertyValue("--icb-bottom-offset")).toBe("0px");
});
it("safely publishes zero offsets without visualViewport, including focus and timeout tails", () => {
const windowAdd = vi.spyOn(window, "addEventListener");
Object.defineProperty(window, "visualViewport", { configurable: true, value: undefined });
const field = document.createElement("textarea"); document.body.append(field); field.focus();
expect(runScript).not.toThrow();
expect(windowAdd).toHaveBeenCalledWith("resize", expect.any(Function));
window.dispatchEvent(new Event("resize"));
expect(document.documentElement.style.getPropertyValue("--icb-bottom-offset")).toBe("0px");
expect(document.documentElement.style.getPropertyValue("--icb-right-offset")).toBe("0px");
vi.advanceTimersByTime(3000);
expect(document.documentElement.style.getPropertyValue("--icb-bottom-offset")).toBe("0px");
expect(document.documentElement.style.getPropertyValue("--icb-right-offset")).toBe("0px");
windowAdd.mockRestore();
});
it("preserves unfocused and pinch-zoom compensation", () => {
installViewport(508); runScript();
expect(document.documentElement.style.getPropertyValue("--icb-bottom-offset")).toBe("336px");
document.body.replaceChildren(); const field = document.createElement("textarea"); document.body.append(field); field.focus();
installViewport(508, 1.5); runScript();
expect(document.documentElement.style.getPropertyValue("--icb-bottom-offset")).toBe("336px");
});
});

View File

@@ -10,10 +10,17 @@ describe("mobile bottom bars keyboard-open css contract", () => {
expect(match![1]).toContain("bottom: 0");
});
it("mobile nav with-footer keyboard-open selector also pins bottom to 0", () => {
const match = css.match(/\.mobile-nav-bar\.mobile-nav-bar--keyboard-open,\s*\.mobile-nav-bar\.mobile-nav-bar--with-footer\.mobile-nav-bar--keyboard-open\s*\{([^}]*)\}/m);
expect(match).toBeTruthy();
expect(match![1]).toContain("bottom: 0");
it("both portrait and viewport-mode keyboard-open copies pin and translate the nav", () => {
const copies = [
/\.mobile-nav-bar\.mobile-nav-bar--keyboard-open,\s*\.mobile-nav-bar\.mobile-nav-bar--with-footer\.mobile-nav-bar--keyboard-open\s*\{([^}]*)\}/m,
/html\[data-viewport-mode="mobile"\] \.mobile-nav-bar\.mobile-nav-bar--keyboard-open,\s*html\[data-viewport-mode="mobile"\] \.mobile-nav-bar\.mobile-nav-bar--with-footer\.mobile-nav-bar--keyboard-open\s*\{([^}]*)\}/m,
];
for (const selector of copies) {
const match = css.match(selector);
expect(match).toBeTruthy();
expect(match![1]).toContain("bottom: 0");
expect(match![1]).toContain("transform: translateY(100%)");
}
});
it("mobile nav keyboard-open rule appears after with-footer rule", () => {

View File

@@ -16,9 +16,10 @@ describe("index.html viewport compensation keyboard guard", () => {
expect(indexHtml).toContain("vvScale <= 1.01");
});
it("contains viewportOffset.ts sync marker and keyboard shrink clamp", () => {
it("contains viewportOffset.ts sync marker and focused keyboard clamp", () => {
expect(indexHtml).toContain("Keep in sync with packages/dashboard/app/utils/viewportOffset.ts");
expect(indexHtml).toContain("activeElementIsKeyboardFocusable && vvScale <= 1.01");
expect(indexHtml).toContain("bottomOffset = 0");
expect(indexHtml).toContain("keyboardShrink");
expect(indexHtml).toContain("rawBottomOffset - keyboardShrink");
});
});

View File

@@ -0,0 +1,65 @@
import { render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MobileNavBar } from "../MobileNavBar";
import { loadAllAppCss } from "../../test/cssFixture";
const css = loadAllAppCss();
function renderNav(keyboardOpen: boolean) {
return render(<MobileNavBar view="board" onChangeView={() => undefined} footerVisible keyboardOpen={keyboardOpen} />);
}
function installCssHost(mode?: "mobile") {
const style = document.createElement("style");
style.textContent = css;
document.head.append(style);
if (mode) document.documentElement.dataset.viewportMode = mode;
return style;
}
describe("MobileNavBar keyboard lift CSS", () => {
let style: HTMLStyleElement;
beforeEach(() => {
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query.includes("max-width: 768px"), media: query,
addEventListener: vi.fn(), removeEventListener: vi.fn(),
})),
});
style = installCssHost();
});
afterEach(() => {
style.remove();
delete document.documentElement.dataset.viewportMode;
document.body.replaceChildren();
});
it("pins and slides the portrait media-query nav class through the CSS cascade", () => {
const { container } = renderNav(true);
const nav = container.querySelector<HTMLElement>(".mobile-nav-bar");
expect(nav).not.toBeNull();
expect(nav).toHaveClass("mobile-nav-bar--keyboard-open");
const resolved = getComputedStyle(nav!);
expect(resolved.bottom).toBe("0px");
expect(resolved.transform).toContain("translateY(100%)");
});
it("pins and slides the landscape data-viewport-mode nav class through the CSS cascade", () => {
style.remove();
style = installCssHost("mobile");
const { container } = renderNav(true);
const nav = container.querySelector<HTMLElement>(".mobile-nav-bar");
expect(nav).not.toBeNull();
const resolved = getComputedStyle(nav!);
expect(resolved.bottom).toBe("0px");
expect(resolved.transform).toContain("translateY(100%)");
});
it("keeps the resting ICB bottom offset when the keyboard class is absent", () => {
const { container } = renderNav(false);
const nav = container.querySelector<HTMLElement>(".mobile-nav-bar");
expect(getComputedStyle(nav!).bottom).toContain("var(--icb-bottom-offset, 0px)");
});
});

View File

@@ -0,0 +1,99 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useKeyboardFocusPending } from "../useKeyboardFocusPending";
function installViewport(scale = 1) {
const target = new EventTarget();
const addEventListener = vi.fn(target.addEventListener.bind(target));
const removeEventListener = vi.fn(target.removeEventListener.bind(target));
const viewport = { scale, addEventListener, removeEventListener, dispatchEvent: target.dispatchEvent.bind(target) };
Object.defineProperty(window, "visualViewport", { configurable: true, value: viewport });
return viewport;
}
function focus(element: HTMLElement) {
act(() => { element.focus(); element.dispatchEvent(new FocusEvent("focusin", { bubbles: true })); });
}
describe("useKeyboardFocusPending", () => {
beforeEach(() => document.body.replaceChildren());
afterEach(() => {
document.body.replaceChildren();
Object.defineProperty(window, "visualViewport", { configurable: true, value: undefined });
});
it("tracks textarea and text input focus, then clears on blur", () => {
installViewport();
const textarea = document.createElement("textarea");
const input = document.createElement("input");
document.body.append(textarea, input);
const { result } = renderHook(() => useKeyboardFocusPending(true));
focus(textarea);
expect(result.current).toBe(true);
act(() => { textarea.blur(); textarea.dispatchEvent(new FocusEvent("focusout", { bubbles: true })); });
expect(result.current).toBe(false);
focus(input);
expect(result.current).toBe(true);
});
it("never treats checkbox, button, or range input focus as keyboard pending", () => {
installViewport();
const { result } = renderHook(() => useKeyboardFocusPending(true));
for (const type of ["checkbox", "button", "range"]) {
const input = document.createElement("input");
input.type = type;
document.body.append(input);
focus(input);
expect(result.current).toBe(false);
input.remove();
}
});
it("clears a focused field while pinch zoom is active and updates on viewport events", () => {
const viewport = installViewport(1);
const textarea = document.createElement("textarea");
document.body.append(textarea);
const { result } = renderHook(() => useKeyboardFocusPending(true));
focus(textarea);
expect(result.current).toBe(true);
viewport.scale = 1.5;
act(() => viewport.dispatchEvent(new Event("resize")));
expect(result.current).toBe(false);
});
it("is disabled without registering any focus or viewport listeners", () => {
const viewport = installViewport();
const windowAdd = vi.spyOn(window, "addEventListener");
const { result, unmount } = renderHook(() => useKeyboardFocusPending(false));
expect(result.current).toBe(false);
expect(windowAdd).not.toHaveBeenCalledWith("focusin", expect.any(Function));
expect(viewport.addEventListener).not.toHaveBeenCalled();
unmount();
windowAdd.mockRestore();
});
it("safely works without visualViewport and removes every listener on unmount", () => {
Object.defineProperty(window, "visualViewport", { configurable: true, value: undefined });
const windowAdd = vi.spyOn(window, "addEventListener");
const windowRemove = vi.spyOn(window, "removeEventListener");
const textarea = document.createElement("textarea");
document.body.append(textarea);
const { result, unmount } = renderHook(() => useKeyboardFocusPending(true));
focus(textarea);
expect(result.current).toBe(true);
expect(windowAdd).toHaveBeenCalledWith("focusin", expect.any(Function));
unmount();
expect(windowRemove).toHaveBeenCalledWith("focusin", expect.any(Function));
expect(windowRemove).toHaveBeenCalledWith("focusout", expect.any(Function));
windowAdd.mockRestore();
windowRemove.mockRestore();
});
it("removes visual viewport resize and scroll listeners on unmount", () => {
const viewport = installViewport();
const { unmount } = renderHook(() => useKeyboardFocusPending(true));
unmount();
expect(viewport.removeEventListener).toHaveBeenCalledWith("resize", expect.any(Function));
expect(viewport.removeEventListener).toHaveBeenCalledWith("scroll", expect.any(Function));
});
});

View File

@@ -0,0 +1,43 @@
import { useEffect, useState } from "react";
import { isKeyboardFocusableInputType } from "../utils/viewportOffset";
function isKeyboardFocusableElement(element: Element | null): boolean {
if (!element) return false;
if (element instanceof HTMLTextAreaElement) return true;
if (element instanceof HTMLInputElement) return isKeyboardFocusableInputType(element.type);
return element instanceof HTMLElement && element.isContentEditable === true;
}
/**
* Reports the focus transition that precedes a settled visual-viewport keyboard sample.
* This intentionally shares the ICB predicate so non-text controls never hide mobile chrome.
*/
export function useKeyboardFocusPending(enabled: boolean): boolean {
const [pending, setPending] = useState(false);
useEffect(() => {
if (!enabled || typeof window === "undefined") {
setPending(false);
return;
}
const update = () => {
const scale = window.visualViewport?.scale ?? 1;
setPending(scale <= 1.01 && isKeyboardFocusableElement(document.activeElement));
};
const viewport = window.visualViewport;
window.addEventListener("focusin", update);
window.addEventListener("focusout", update);
viewport?.addEventListener("resize", update);
viewport?.addEventListener("scroll", update);
update();
return () => {
window.removeEventListener("focusin", update);
window.removeEventListener("focusout", update);
viewport?.removeEventListener("resize", update);
viewport?.removeEventListener("scroll", update);
};
}, [enabled]);
return pending;
}

View File

@@ -67,10 +67,16 @@
}
// Keep in sync with packages/dashboard/app/utils/viewportOffset.ts.
// Ignore keyboard-driven visualViewport shrink (iOS) so fixed mobile
// bars stay pinned to page bottom and are covered by keyboard.
/*
FNXC:ViewportChrome 2026-08-23-18:14:
While a keyboard-focusable element is focused at scale <= 1.01, fixed bottom bars must stay pinned to the page bottom and be covered by the keyboard. A stale or absent baseline must never lift them.
*/
var bottomOffset = rawBottomOffset;
if (activeElementIsKeyboardFocusable && vvScale <= 1.01 && baselineViewportHeight !== null) {
if (activeElementIsKeyboardFocusable && vvScale <= 1.01) {
bottomOffset = 0;
} else if (activeElementIsKeyboardFocusable && vvScale > 1.01) {
bottomOffset = rawBottomOffset;
} else if (activeElementIsKeyboardFocusable && baselineViewportHeight !== null) {
var keyboardShrink = Math.max(0, baselineViewportHeight - vvH);
if (keyboardShrink > 0) {
bottomOffset = Math.max(0, rawBottomOffset - keyboardShrink);

View File

@@ -9,6 +9,7 @@ describe("computeMobileBarKeyboardFlags", () => {
// with no dead band. `footerKeyboardOpen` stays iOS-only.
const flags = computeMobileBarKeyboardFlags({
isMobile: true,
keyboardFocusPending: false,
keyboardOpen: true,
anyModalOpen: false,
overlayOpen: false,
@@ -23,6 +24,7 @@ describe("computeMobileBarKeyboardFlags", () => {
it("hides and collapses footer on iOS when keyboard is open and no overlay is open", () => {
const flags = computeMobileBarKeyboardFlags({
isMobile: true,
keyboardFocusPending: false,
keyboardOpen: true,
anyModalOpen: false,
overlayOpen: false,
@@ -37,6 +39,7 @@ describe("computeMobileBarKeyboardFlags", () => {
it("keeps footer visible when iOS keyboard is open over a modal", () => {
const flags = computeMobileBarKeyboardFlags({
isMobile: true,
keyboardFocusPending: false,
keyboardOpen: true,
anyModalOpen: true,
overlayOpen: false,
@@ -48,9 +51,23 @@ describe("computeMobileBarKeyboardFlags", () => {
expect(flags.navKeyboardOpen).toBe(true);
});
it("slides the nav on focus without changing settled footer behavior", () => {
const flags = computeMobileBarKeyboardFlags({
isMobile: true,
keyboardFocusPending: true,
keyboardOpen: false,
anyModalOpen: false,
overlayOpen: false,
isIOS: true,
});
expect(flags).toEqual({ footerHidden: false, navKeyboardOpen: true, footerKeyboardOpen: false });
});
it("returns all false when keyboard is closed", () => {
const flags = computeMobileBarKeyboardFlags({
isMobile: true,
keyboardFocusPending: false,
keyboardOpen: false,
anyModalOpen: false,
overlayOpen: false,
@@ -67,6 +84,7 @@ describe("computeMobileBarKeyboardFlags", () => {
it("returns all false when not mobile", () => {
const flags = computeMobileBarKeyboardFlags({
isMobile: false,
keyboardFocusPending: false,
keyboardOpen: true,
anyModalOpen: false,
overlayOpen: false,
@@ -83,6 +101,7 @@ describe("computeMobileBarKeyboardFlags", () => {
it("keeps the board footer visible on iOS when a fullscreen overlay owns the keyboard", () => {
const flags = computeMobileBarKeyboardFlags({
isMobile: true,
keyboardFocusPending: false,
keyboardOpen: true,
anyModalOpen: false,
overlayOpen: true,
@@ -97,6 +116,7 @@ describe("computeMobileBarKeyboardFlags", () => {
it("keeps Android board-layout behavior unchanged when a fullscreen overlay owns the keyboard", () => {
const flags = computeMobileBarKeyboardFlags({
isMobile: true,
keyboardFocusPending: false,
keyboardOpen: true,
anyModalOpen: false,
overlayOpen: true,
@@ -111,6 +131,7 @@ describe("computeMobileBarKeyboardFlags", () => {
it("suppresses the original iOS board-shift trigger only when the Quick Chat overlay flag is set", () => {
const originalBoardShiftTrigger = computeMobileBarKeyboardFlags({
isMobile: true,
keyboardFocusPending: false,
keyboardOpen: true,
anyModalOpen: false,
overlayOpen: false,
@@ -118,6 +139,7 @@ describe("computeMobileBarKeyboardFlags", () => {
});
const quickChatOverlayKeyboard = computeMobileBarKeyboardFlags({
isMobile: true,
keyboardFocusPending: false,
keyboardOpen: true,
anyModalOpen: false,
overlayOpen: true,

View File

@@ -34,6 +34,38 @@ describe("computeIcbOffsets", () => {
expect(offsets.bottomOffset).toBe(0);
});
it("does not lift fixed bars for a focused field without a baseline", () => {
const offsets = computeIcbOffsets({
innerWidth: 390,
innerHeight: 844,
vvWidth: 390,
vvHeight: 508,
vvOffsetTop: 0,
vvOffsetLeft: 0,
vvScale: 1,
activeElementIsKeyboardFocusable: true,
baselineViewportHeight: null,
});
expect(offsets.bottomOffset).toBe(0);
});
it("does not retain a stale focused baseline residual", () => {
const offsets = computeIcbOffsets({
innerWidth: 390,
innerHeight: 844,
vvWidth: 390,
vvHeight: 508,
vvOffsetTop: 0,
vvOffsetLeft: 0,
vvScale: 1,
activeElementIsKeyboardFocusable: true,
baselineViewportHeight: 754,
});
expect(offsets.bottomOffset).toBe(0);
});
it("preserves pinch-zoom offset compensation", () => {
const offsets = computeIcbOffsets({
innerWidth: 390,

View File

@@ -1,6 +1,7 @@
export interface MobileBarKeyboardFlagsInput {
isMobile: boolean;
keyboardOpen: boolean;
keyboardFocusPending: boolean;
anyModalOpen: boolean;
/** True when a fullscreen mobile overlay owns keyboard/viewport layout. */
overlayOpen: boolean;
@@ -28,14 +29,19 @@ Fullscreen mobile overlays (for example Quick Chat's sheet) own their own visual
export function computeMobileBarKeyboardFlags({
isMobile,
keyboardOpen,
keyboardFocusPending,
anyModalOpen,
overlayOpen,
isIOS,
}: MobileBarKeyboardFlagsInput): MobileBarKeyboardFlags {
/*
FNXC:MobileChatKeyboardLayout 2026-08-23-18:14:
The nav bar's off-screen slide must not wait for a settled visualViewport sample: a withheld or late metric previously left it visible and lifted on individual keyboard opens. The padding strip and iOS footer collapse stay settled-gated because FN-5707 constrained them.
*/
const boardLayoutSuppressed = anyModalOpen || overlayOpen;
const footerHidden = isMobile && keyboardOpen && !boardLayoutSuppressed;
const navKeyboardOpen = isMobile && keyboardOpen;
const footerKeyboardOpen = navKeyboardOpen && isIOS;
const navKeyboardOpen = isMobile && (keyboardOpen || keyboardFocusPending);
const footerKeyboardOpen = isMobile && keyboardOpen && isIOS;
return {
footerHidden,

View File

@@ -48,6 +48,14 @@ export function computeIcbOffsets(input: ComputeIcbOffsetsInput): IcbOffsets {
const rightOffset = Math.max(0, innerWidth - vvOffsetLeft - vvWidth);
const rawBottomOffset = Math.max(0, innerHeight - vvOffsetTop - vvHeight);
/*
FNXC:ViewportChrome 2026-08-23-18:14:
While a keyboard-focusable element is focused at scale <= 1.01, fixed bottom bars must stay pinned to the page bottom and be covered by the keyboard. A stale or absent baseline must never lift them.
*/
if (activeElementIsKeyboardFocusable && vvScale <= 1.01) {
return { rightOffset, bottomOffset: 0 };
}
if (!activeElementIsKeyboardFocusable || vvScale > 1.01 || baselineViewportHeight == null) {
return { rightOffset, bottomOffset: rawBottomOffset };
}