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
This commit is contained in:
gsxdsm
2026-06-08 14:22:03 -07:00
parent f7daa6e347
commit 1d0921e069
4 changed files with 151 additions and 6 deletions

View File

@@ -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);
});
});

View File

@@ -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)
);
}
}

View File

@@ -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<HTMLElement>(".mobile-nav-tab"), (tab) => tab.getBoundingClientRect().height);
const publishedHeight = computePublishedMobileNavHeight({
navOffsetHeight: navEl.offsetHeight,
paddingBottom,
tabHeights,
});
document.documentElement.style.setProperty("--mobile-nav-height", `${publishedHeight}px`);
};

View File

@@ -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",