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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user