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.
|
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
|
### 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/*`.
|
- 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 "./MobileNavBar.css";
|
||||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Bot,
|
Bot,
|
||||||
@@ -152,6 +152,7 @@ export function MobileNavBar({
|
|||||||
const [isScriptsSubmenuOpen, setIsScriptsSubmenuOpen] = useState(false);
|
const [isScriptsSubmenuOpen, setIsScriptsSubmenuOpen] = useState(false);
|
||||||
const [scripts, setScripts] = useState<Record<string, string>>({});
|
const [scripts, setScripts] = useState<Record<string, string>>({});
|
||||||
const [scriptsLoading, setScriptsLoading] = useState(false);
|
const [scriptsLoading, setScriptsLoading] = useState(false);
|
||||||
|
const navRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
const scriptEntries = useMemo(
|
const scriptEntries = useMemo(
|
||||||
() => Object.entries(scripts).sort(([a], [b]) => a.localeCompare(b)),
|
() => Object.entries(scripts).sort(([a], [b]) => a.localeCompare(b)),
|
||||||
@@ -204,6 +205,36 @@ export function MobileNavBar({
|
|||||||
return () => document.removeEventListener("keydown", onKeyDown);
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
}, [isMoreOpen]);
|
}, [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) {
|
if (mode !== "mobile" || modalOpen || keyboardOpen) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -249,6 +280,7 @@ export function MobileNavBar({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<nav
|
<nav
|
||||||
|
ref={navRef}
|
||||||
className={`mobile-nav-bar${footerVisible ? " mobile-nav-bar--with-footer" : ""}`}
|
className={`mobile-nav-bar${footerVisible ? " mobile-nav-bar--with-footer" : ""}`}
|
||||||
role="tablist"
|
role="tablist"
|
||||||
aria-label="Primary navigation"
|
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 { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { MobileNavBar } from "../MobileNavBar";
|
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