From 1d0921e069efd5fc6b5645c43f8ed2d256a1170e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 8 Jun 2026 14:22:03 -0700 Subject: [PATCH] FN-6032: fix mobile dashboard bottom-space padding Keep mobile dashboard content aligned with the visible bottom-bar stack. - include ICB bottom-offset compensation in mobile project content padding - publish nav height from measured tab boxes with a safe fallback for unresolved safe-area padding - add a mobile bottom-space regression test and include it in the dashboard UI shard Files changed: .../__tests__/mobile-bottom-space-layout.test.ts | 118 +++++++++++++++++++++ packages/dashboard/app/components/MobileNavBar.css | 7 +- packages/dashboard/app/components/MobileNavBar.tsx | 31 +++++- packages/dashboard/vitest.config.ts | 1 + 4 files changed, 151 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-6032 Fusion-Task-Lineage: bcbf54e2-c089-40c5-b33c-35c39d981443 --- .../mobile-bottom-space-layout.test.ts | 118 ++++++++++++++++++ .../dashboard/app/components/MobileNavBar.css | 7 +- .../dashboard/app/components/MobileNavBar.tsx | 31 ++++- packages/dashboard/vitest.config.ts | 1 + 4 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 packages/dashboard/app/__tests__/mobile-bottom-space-layout.test.ts diff --git a/packages/dashboard/app/__tests__/mobile-bottom-space-layout.test.ts b/packages/dashboard/app/__tests__/mobile-bottom-space-layout.test.ts new file mode 100644 index 0000000000..cb259eed67 --- /dev/null +++ b/packages/dashboard/app/__tests__/mobile-bottom-space-layout.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import { loadAllAppCss } from "../test/cssFixture"; +import { computePublishedMobileNavHeight } from "../components/MobileNavBar"; + +function extractMobileMediaBlocks(content: string): string { + const blocks: string[] = []; + const regex = /@media[^{}]*\(max-width:\s*768px\)[^{]*\{/g; + let match: RegExpExecArray | null; + + while ((match = regex.exec(content)) !== null) { + const startIdx = match.index + match[0].length; + let braceCount = 1; + let endIdx = startIdx; + while (braceCount > 0 && endIdx < content.length) { + if (content[endIdx] === "{") braceCount += 1; + if (content[endIdx] === "}") braceCount -= 1; + endIdx += 1; + } + if (braceCount === 0) { + blocks.push(content.slice(startIdx, endIdx - 1)); + } + } + + return blocks.join("\n"); +} + +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] ?? ""; +} + +function normalizeCss(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +interface BottomStackInput { + footerVisible: boolean; + mobileNavVisible: boolean; + keyboardOpen: boolean; + safeAreaFloor: number; + standaloneGap: number; + icbBottomOffset: number; + executorFooterHeight: number; + mobileNavHeight: number; +} + +function visibleFixedBottomStack(input: BottomStackInput): number { + if (input.keyboardOpen || !input.mobileNavVisible) return 0; + + const navSurface = input.mobileNavHeight + input.safeAreaFloor + input.standaloneGap; + const footer = input.footerVisible ? input.executorFooterHeight : 0; + return navSurface + footer + input.icbBottomOffset; +} + +function reservedProjectContentBottom(input: BottomStackInput): number { + if (input.keyboardOpen || !input.mobileNavVisible) return 0; + + const navSurface = input.mobileNavHeight + input.safeAreaFloor + input.standaloneGap; + const footer = input.footerVisible ? input.executorFooterHeight : 0; + return navSurface + footer + input.icbBottomOffset; +} + +describe("mobile bottom-space layout invariant", () => { + const css = loadAllAppCss(); + const mobileCss = extractMobileMediaBlocks(css); + + it.each([ + ["nav only / healthy viewport", { footerVisible: false, mobileNavVisible: true, keyboardOpen: false, safeAreaFloor: 12, standaloneGap: 0, icbBottomOffset: 0, executorFooterHeight: 36, mobileNavHeight: 44 }], + ["footer + nav / iPhone PWA safe area", { footerVisible: true, mobileNavVisible: true, keyboardOpen: false, safeAreaFloor: 34, standaloneGap: 8, icbBottomOffset: 0, executorFooterHeight: 36, mobileNavHeight: 44 }], + ["footer + nav / compensated visual viewport", { footerVisible: true, mobileNavVisible: true, keyboardOpen: false, safeAreaFloor: 34, standaloneGap: 8, icbBottomOffset: 52, executorFooterHeight: 36, mobileNavHeight: 44 }], + ["keyboard open / bars covered", { footerVisible: true, mobileNavVisible: true, keyboardOpen: true, safeAreaFloor: 34, standaloneGap: 8, icbBottomOffset: 0, executorFooterHeight: 36, mobileNavHeight: 44 }], + ["mobile nav hidden", { footerVisible: false, mobileNavVisible: false, keyboardOpen: false, safeAreaFloor: 34, standaloneGap: 8, icbBottomOffset: 0, executorFooterHeight: 36, mobileNavHeight: 44 }], + ] satisfies Array<[string, BottomStackInput]>)("reserves exactly the visible fixed stack for %s", (_name, input) => { + expect(reservedProjectContentBottom(input)).toBe(visibleFixedBottomStack(input)); + }); + + it("mobile project-content reservation includes only fixed bottom-bar stack terms", () => { + const navOnlyRule = normalizeCss(extractRuleBlock(mobileCss, ".project-content--with-mobile-nav:not(.project-content--with-footer)")); + const footerAndNavRule = normalizeCss(extractRuleBlock(mobileCss, ".project-content--with-footer.project-content--with-mobile-nav")); + + expect(navOnlyRule).toContain("padding-bottom: calc(var(--mobile-nav-height) + max(env(safe-area-inset-bottom, 0px), 12px) + var(--standalone-bottom-gap) + var(--icb-bottom-offset, 0px))"); + expect(footerAndNavRule).toContain("var(--executor-footer-height) + var(--mobile-nav-height) + max(env(safe-area-inset-bottom, 0px), 12px) + var(--standalone-bottom-gap) + var(--icb-bottom-offset, 0px)"); + expect(footerAndNavRule).not.toContain("100vh"); + expect(footerAndNavRule).not.toContain("100dvh"); + }); + + it("keeps board and list content height parent-relative on mobile and desktop", () => { + const boardRule = extractRuleBlock(css, ".board"); + const listRule = extractRuleBlock(css, ".list-view"); + const mobileBoardRule = extractRuleBlock(mobileCss, ".board"); + const mobileListRule = extractRuleBlock(mobileCss, ".list-view"); + + expect(boardRule).toContain("height: 100%"); + expect(listRule).toContain("height: 100%"); + expect(`${mobileBoardRule}\n${mobileListRule}`).not.toMatch(/height\s*:\s*(?:calc\()?100d?vh/); + }); + + it("publishes nav content height from tab boxes when Safari leaves safe-area padding unresolved", () => { + expect( + computePublishedMobileNavHeight({ + navOffsetHeight: 122, + paddingBottom: Number.NaN, + tabHeights: [44, 44, 44], + }), + ).toBe(44); + }); + + it("falls back to offset minus resolved padding when tab boxes are unavailable", () => { + expect( + computePublishedMobileNavHeight({ + navOffsetHeight: 86, + paddingBottom: 42, + tabHeights: [], + }), + ).toBe(44); + }); +}); diff --git a/packages/dashboard/app/components/MobileNavBar.css b/packages/dashboard/app/components/MobileNavBar.css index 32b6efdc82..61ae3aaf11 100644 --- a/packages/dashboard/app/components/MobileNavBar.css +++ b/packages/dashboard/app/components/MobileNavBar.css @@ -53,15 +53,16 @@ bottom: 0; } - /* Content padding: mobile nav only (no footer). Bar is flush at bottom (no safe-area pad). */ + /* Content padding: mobile nav only (no footer). Mirrors the visible fixed stack, + including ICB compensation when fixed bars are pulled into the visual viewport. */ .project-content--with-mobile-nav:not(.project-content--with-footer) { - padding-bottom: calc(var(--mobile-nav-height) + max(env(safe-area-inset-bottom, 0px), 12px) + var(--standalone-bottom-gap)); + padding-bottom: calc(var(--mobile-nav-height) + max(env(safe-area-inset-bottom, 0px), 12px) + var(--standalone-bottom-gap) + var(--icb-bottom-offset, 0px)); } /* Content padding: both mobile nav AND footer */ .project-content--with-footer.project-content--with-mobile-nav { padding-bottom: calc( - var(--executor-footer-height) + var(--mobile-nav-height) + max(env(safe-area-inset-bottom, 0px), 12px) + var(--standalone-bottom-gap) + var(--executor-footer-height) + var(--mobile-nav-height) + max(env(safe-area-inset-bottom, 0px), 12px) + var(--standalone-bottom-gap) + var(--icb-bottom-offset, 0px) ); } } diff --git a/packages/dashboard/app/components/MobileNavBar.tsx b/packages/dashboard/app/components/MobileNavBar.tsx index 6ae76d6df0..2b53505396 100644 --- a/packages/dashboard/app/components/MobileNavBar.tsx +++ b/packages/dashboard/app/components/MobileNavBar.tsx @@ -39,6 +39,27 @@ import type { TaskView } from "../hooks/useViewState"; import { buildPluginTaskViewId, isPluginViewId } from "../plugins/pluginViewRegistry"; import { getPluginNavIcon } from "./pluginNavIcon"; +export interface PublishedMobileNavHeightInput { + navOffsetHeight: number; + paddingBottom: number; + tabHeights: number[]; +} + +export function computePublishedMobileNavHeight({ + navOffsetHeight, + paddingBottom, + tabHeights, +}: PublishedMobileNavHeightInput): number { + const measuredTabHeight = Math.max(0, ...tabHeights.filter((height) => Number.isFinite(height))); + if (measuredTabHeight > 0) { + return Math.max(44, Math.ceil(measuredTabHeight)); + } + + const resolvedPaddingBottom = Number.isFinite(paddingBottom) ? paddingBottom : 0; + const contentHeight = navOffsetHeight - resolvedPaddingBottom; + return Math.max(44, Math.ceil(contentHeight)); +} + export interface MobileNavBarProps { /** Current task view mode */ view: TaskView; @@ -215,9 +236,13 @@ export function MobileNavBar({ 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)); + const paddingBottom = Number.parseFloat(computed.paddingBottom); + const tabHeights = Array.from(navEl.querySelectorAll(".mobile-nav-tab"), (tab) => tab.getBoundingClientRect().height); + const publishedHeight = computePublishedMobileNavHeight({ + navOffsetHeight: navEl.offsetHeight, + paddingBottom, + tabHeights, + }); document.documentElement.style.setProperty("--mobile-nav-height", `${publishedHeight}px`); }; diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts index 2366fa0a44..2f4612b990 100644 --- a/packages/dashboard/vitest.config.ts +++ b/packages/dashboard/vitest.config.ts @@ -57,6 +57,7 @@ const qualityAppFoundationUiTests = [ "app/__tests__/lazy-loaded-views-docs.test.ts", "app/__tests__/mission-planning-modals-mobile.test.ts", "app/__tests__/mobile-bottom-bars-keyboard-layout.test.ts", + "app/__tests__/mobile-bottom-space-layout.test.ts", "app/__tests__/mobile-feature-access-regression.test.tsx", "app/__tests__/mobile-header-controls.test.ts", "app/__tests__/mobile-input-font-size.test.ts",