Fix iPhone mobile header safe-area spacing

Add iOS safe-area viewport support and inset-aware mobile header/spacer
sizing so controls clear the notch or Dynamic Island. Desktop and tablet
headers remain unchanged. Includes focused unit and mobile browser
regression coverage.
This commit is contained in:
Jeppe B
2026-07-20 16:16:56 +02:00
committed by GitHub
parent c69f4f7fc7
commit 133e53cfa6
6 changed files with 77 additions and 5 deletions
+1 -1
View File
@@ -19,7 +19,7 @@
<link rel="shortcut icon" href="%BASE_URL%assets/favicons/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="%BASE_URL%assets/favicons/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Truck Wash Kundeportal" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.1/css/all.min.css" integrity="sha512-5Hs3dF2AEPkpNAR7UiOHba+lRSJNeM2ECkwxUIxC1Q/FLycGTbNapWXB4tP889k5T5Ju8fs4b1P5z/iB4nMfSQ==" crossorigin="anonymous" referrerpolicy="no-referrer" />
+1
View File
@@ -27,6 +27,7 @@ export const ownedFilesByRole = {
"i18n.smoke.spec.ts",
"i18n.views.spec.ts",
"limited-backoffice.spec.ts",
"mobile-header-safe-area.spec.ts",
"navigation.smoke.spec.js",
"qr-new-customer-layout.spec.ts",
"release-bootstrap.spec.js",
+1
View File
@@ -19,6 +19,7 @@ textarea.has-sharp-edges {
/*}*/
:root {
--pleno-mobile-safe-top: env(safe-area-inset-top, 0px);
/* 1. Primary Brand Colors (HSL) */
--bulma-primary-h: 197deg !important;
--bulma-primary-l: 35% !important;
@@ -117,10 +117,13 @@ const canShowProfileActions = computed(() => showProfile.value && (canShowAdminS
margin-left: 1rem;
}
.mobile-header-section {
height: 80px;
height: calc(80px + var(--pleno-mobile-safe-top));
min-height: calc(80px + var(--pleno-mobile-safe-top));
width: 100%;
display: flex;
align-items: center;
padding-top: var(--pleno-mobile-safe-top);
box-sizing: border-box;
background-color: white;
}
:deep(.mobile-queue-monitor.queue-monitor-navbar-item) {
@@ -152,9 +155,9 @@ const canShowProfileActions = computed(() => showProfile.value && (canShowAdminS
margin-right: 1rem;
}
.navbar-spacer {
height: 80px; /* Same height as the navbar */
height: calc(80px + var(--pleno-mobile-safe-top));
min-width: 100%;
min-height: 80px; /* Ensures the spacer is always at least as tall as the navbar */
min-height: calc(80px + var(--pleno-mobile-safe-top));
background-color: transparent; /* Transparent to avoid visual interference */
position: relative; /* Allows for proper stacking context */
}
@@ -191,9 +194,11 @@ const canShowProfileActions = computed(() => showProfile.value && (canShowAdminS
position: absolute;
width: 100%;
height: 80px;
height: calc(80px + var(--pleno-mobile-safe-top));
left: 0px;
top: 0px;
padding-top: var(--pleno-mobile-safe-top);
box-sizing: border-box;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
+37
View File
@@ -0,0 +1,37 @@
import { expect, test } from "@playwright/test";
test("mobile header clears the simulated iPhone safe area", async ({ page }, testInfo) => {
test.skip(!["chromium-mobile", "webkit-mobile"].includes(testInfo.project.name), "Mobile browser coverage only.");
await page.goto("/login", { waitUntil: "domcontentloaded" });
const header = page.locator("nav.mobile-header-section");
const spacer = page.locator(".navbar-spacer");
await expect(header).toBeVisible();
await page.evaluate(() => {
document.documentElement.style.setProperty("--pleno-mobile-safe-top", "44px");
});
const insetLayout = await header.evaluate((element) => {
const headerBox = element.getBoundingClientRect();
const spacer = document.querySelector(".navbar-spacer");
const spacerBox = spacer?.getBoundingClientRect();
return {
headerHeight: headerBox.height,
headerPaddingTop: getComputedStyle(element).paddingTop,
spacerHeight: spacerBox?.height ?? 0,
};
});
expect(insetLayout.headerHeight).toBe(124);
expect(insetLayout.headerPaddingTop).toBe("44px");
expect(insetLayout.spacerHeight).toBe(124);
await page.evaluate(() => {
document.documentElement.style.setProperty("--pleno-mobile-safe-top", "0px");
});
await expect.poll(() => header.evaluate((element) => element.getBoundingClientRect().height)).toBe(80);
await expect.poll(() => spacer.evaluate((element) => element.getBoundingClientRect().height)).toBe(80);
});
@@ -0,0 +1,28 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const root = process.cwd();
const indexSource = readFileSync(join(root, "index.html"), "utf8");
const mainStylesSource = readFileSync(join(root, "src/assets/main.css"), "utf8");
const mobileHeaderSource = readFileSync(join(root, "src/components/viewport/page/headers/MobileHeader.vue"), "utf8");
const desktopHeaderSource = readFileSync(join(root, "src/components/viewport/page/headers/DesktopHeader.vue"), "utf8");
const tabletHeaderSource = readFileSync(join(root, "src/components/viewport/page/headers/TabletHeader.vue"), "utf8");
describe("mobile header iOS safe-area contract", () => {
it("opts into safe-area viewport geometry", () => {
expect(indexSource).toContain("viewport-fit=cover");
expect(mainStylesSource).toContain("--pleno-mobile-safe-top: env(safe-area-inset-top, 0px);");
});
it("applies the inset to both the fixed header and its content spacer", () => {
expect(mobileHeaderSource).toContain("height: calc(80px + var(--pleno-mobile-safe-top));");
expect(mobileHeaderSource).toContain("padding-top: var(--pleno-mobile-safe-top);");
expect(mobileHeaderSource).toContain("min-height: calc(80px + var(--pleno-mobile-safe-top));");
});
it("does not introduce safe-area offsets into tablet or desktop headers", () => {
expect(desktopHeaderSource).not.toContain("safe-area-inset-top");
expect(tabletHeaderSource).not.toContain("safe-area-inset-top");
});
});