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:
committed by
gsxdsm
parent
4cbdd157c5
commit
496d275595
@@ -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/*`.
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user