feat(FN-5476): add runtime nav height observer for mobile safe area padding

Added a runtime navigation height observer to `MobileNavBar` that captures the browser's dynamic nav-height CSS value at runtime, updated both the nav height test coverage and the Android overlap CSS contract test, and documented the new token in `AGENTS.md`.

Fusion-Task-Id: FN-5476
This commit is contained in:
Fusion (runfusion.ai)
2026-05-21 22:55:30 -07:00
committed by gsxdsm
parent 4cbdd157c5
commit 496d275595
4 changed files with 155 additions and 2 deletions

View File

@@ -12,6 +12,11 @@ If you find yourself opening `SettingsModal.css`, `TaskCard.css`, `ChatView.css`
Exception: explicit named user request in chat that overrides this directive.
### Mobile bottom spacing token contract
- `--mobile-nav-height` keeps a `44px` CSS fallback, then gets republished at runtime by `MobileNavBar.tsx` (`ResizeObserver`) to match live nav content height.
- Bottom offsets should compose as: `var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap)`.
### Spec Generation Hygiene
- Do not cite `.fusion/tasks/<id>/<file>` paths in Context/Steps/File Scope unless the file already exists, is explicitly created as a `(new)` Artifact, or is sibling `PROMPT.md`/`task.json`/`attachments/*`.

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { loadAllAppCss } from "../test/cssFixture";
/**
* Android overlap contract:
* - MobileNavBar keeps a 44px SSR fallback token.
* - MobileNavBar.tsx republishs --mobile-nav-height at runtime from live DOM height.
* - Footer/content offsets consume the token, so Android's taller label line-box no longer overlaps.
*/
function extractRuleBlock(css: string, selector: string): string {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = css.match(new RegExp(`${escapedSelector}\\s*\\{([\\s\\S]*?)\\}`));
return match?.[1] ?? "";
}
describe("mobile nav / executor footer overlap contract", () => {
const css = loadAllAppCss();
it("keeps 44px :root fallback token", () => {
expect(css).toMatch(/:root\s*\{[\s\S]*--mobile-nav-height:\s*44px;/);
});
it("uses min-height var token for the nav bar", () => {
const navBlock = extractRuleBlock(css, ".mobile-nav-bar");
expect(navBlock).toContain("min-height: var(--mobile-nav-height)");
expect(navBlock).not.toContain("height: 44px");
});
it("positions executor footer above nav using the token on mobile", () => {
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)\s*\{[\s\S]*\.executor-status-bar\s*\{[\s\S]*bottom:\s*calc\(var\(--mobile-nav-height\)\s*\+\s*env\(safe-area-inset-bottom,\s*0px\)\s*\+\s*var\(--standalone-bottom-gap\)\)/);
});
it("uses nav-height token in mobile project-content padding rules", () => {
expect(css).toMatch(/\.project-content--with-mobile-nav:not\(\.project-content--with-footer\)\s*\{[\s\S]*padding-bottom:\s*calc\(var\(--mobile-nav-height\)/);
expect(css).toMatch(/\.project-content--with-footer\.project-content--with-mobile-nav\s*\{[\s\S]*padding-bottom:\s*calc\([\s\S]*var\(--mobile-nav-height\)/);
});
});

View File

@@ -1,5 +1,5 @@
import "./MobileNavBar.css";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
Activity,
Bot,
@@ -152,6 +152,7 @@ export function MobileNavBar({
const [isScriptsSubmenuOpen, setIsScriptsSubmenuOpen] = useState(false);
const [scripts, setScripts] = useState<Record<string, string>>({});
const [scriptsLoading, setScriptsLoading] = useState(false);
const navRef = useRef<HTMLElement | null>(null);
const scriptEntries = useMemo(
() => Object.entries(scripts).sort(([a], [b]) => a.localeCompare(b)),
@@ -204,6 +205,36 @@ export function MobileNavBar({
return () => document.removeEventListener("keydown", onKeyDown);
}, [isMoreOpen]);
useLayoutEffect(() => {
const navEl = navRef.current;
if (!navEl || typeof document === "undefined") {
return;
}
const publishMeasuredHeight = () => {
const computed = window.getComputedStyle(navEl);
const paddingBottom = Number.parseFloat(computed.paddingBottom) || 0;
const contentHeight = navEl.offsetHeight - paddingBottom;
const publishedHeight = Math.max(44, Math.ceil(contentHeight));
document.documentElement.style.setProperty("--mobile-nav-height", `${publishedHeight}px`);
};
publishMeasuredHeight();
let observer: ResizeObserver | null = null;
if (typeof ResizeObserver !== "undefined") {
observer = new ResizeObserver(() => {
publishMeasuredHeight();
});
observer.observe(navEl);
}
return () => {
observer?.disconnect();
document.documentElement.style.removeProperty("--mobile-nav-height");
};
}, []);
if (mode !== "mobile" || modalOpen || keyboardOpen) {
return null;
}
@@ -249,6 +280,7 @@ export function MobileNavBar({
return (
<>
<nav
ref={navRef}
className={`mobile-nav-bar${footerVisible ? " mobile-nav-bar--with-footer" : ""}`}
role="tablist"
aria-label="Primary navigation"

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MobileNavBar } from "../MobileNavBar";
@@ -761,4 +761,83 @@ describe("MobileNavBar", () => {
});
});
});
describe("ResizeObserver height measurement", () => {
const originalResizeObserver = globalThis.ResizeObserver;
let resizeObserverCallback: ResizeObserverCallback | null = null;
let offsetHeightValue = 52;
class ResizeObserverMock {
constructor(callback: ResizeObserverCallback) {
resizeObserverCallback = callback;
}
observe() {}
unobserve() {}
disconnect() {}
}
afterEach(() => {
globalThis.ResizeObserver = originalResizeObserver;
resizeObserverCallback = null;
offsetHeightValue = 52;
document.documentElement.style.removeProperty("--mobile-nav-height");
vi.restoreAllMocks();
});
it("publishes measured height on mount and clears inline token on unmount", () => {
globalThis.ResizeObserver = ResizeObserverMock as typeof ResizeObserver;
vi.spyOn(window, "getComputedStyle").mockReturnValue({ paddingBottom: "0px" } as CSSStyleDeclaration);
const { container, unmount } = render(<MobileNavBar {...createDefaultProps()} />);
const navEl = container.querySelector(".mobile-nav-bar") as HTMLElement;
Object.defineProperty(navEl, "offsetHeight", {
configurable: true,
get: () => offsetHeightValue,
});
if (resizeObserverCallback) {
resizeObserverCallback([], {} as ResizeObserver);
}
expect(document.documentElement.style.getPropertyValue("--mobile-nav-height")).toBe("52px");
unmount();
expect(document.documentElement.style.getPropertyValue("--mobile-nav-height")).toBe("");
});
it("updates measured token when ResizeObserver callback fires", () => {
globalThis.ResizeObserver = ResizeObserverMock as typeof ResizeObserver;
vi.spyOn(window, "getComputedStyle").mockReturnValue({ paddingBottom: "0px" } as CSSStyleDeclaration);
const { container } = render(<MobileNavBar {...createDefaultProps()} />);
const navEl = container.querySelector(".mobile-nav-bar") as HTMLElement;
Object.defineProperty(navEl, "offsetHeight", {
configurable: true,
get: () => offsetHeightValue,
});
if (resizeObserverCallback) {
resizeObserverCallback([], {} as ResizeObserver);
}
expect(document.documentElement.style.getPropertyValue("--mobile-nav-height")).toBe("52px");
offsetHeightValue = 58;
if (resizeObserverCallback) {
resizeObserverCallback([{ target: navEl } as ResizeObserverEntry], {} as ResizeObserver);
}
expect(document.documentElement.style.getPropertyValue("--mobile-nav-height")).toBe("58px");
});
it("still performs initial measurement when ResizeObserver is unavailable", () => {
// @ts-expect-error test intentionally clears ResizeObserver
delete globalThis.ResizeObserver;
vi.spyOn(window, "getComputedStyle").mockReturnValue({ paddingBottom: "0px" } as CSSStyleDeclaration);
render(<MobileNavBar {...createDefaultProps()} />);
expect(document.documentElement.style.getPropertyValue("--mobile-nav-height")).toBe("44px");
});
});
});