From 133e53cfa6eda6d3a03ca58f908f4edfbb6f5dc8 Mon Sep 17 00:00:00 2001
From: Jeppe B <2jepp9350@gmail.com>
Date: Mon, 20 Jul 2026 16:16:56 +0200
Subject: [PATCH] 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.
---
index.html | 2 +-
scripts/run-playwright-full-slice.mjs | 1 +
src/assets/main.css | 1 +
.../viewport/page/headers/MobileHeader.vue | 13 +++++--
tests/e2e/mobile-header-safe-area.spec.ts | 37 +++++++++++++++++++
tests/unit/mobile-header-safe-area.spec.js | 28 ++++++++++++++
6 files changed, 77 insertions(+), 5 deletions(-)
create mode 100644 tests/e2e/mobile-header-safe-area.spec.ts
create mode 100644 tests/unit/mobile-header-safe-area.spec.js
diff --git a/index.html b/index.html
index adab9482..4aa4bdcf 100644
--- a/index.html
+++ b/index.html
@@ -19,7 +19,7 @@
-
+
diff --git a/scripts/run-playwright-full-slice.mjs b/scripts/run-playwright-full-slice.mjs
index f8687d5f..38d271aa 100644
--- a/scripts/run-playwright-full-slice.mjs
+++ b/scripts/run-playwright-full-slice.mjs
@@ -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",
diff --git a/src/assets/main.css b/src/assets/main.css
index 07058908..106ebd3e 100644
--- a/src/assets/main.css
+++ b/src/assets/main.css
@@ -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;
diff --git a/src/components/viewport/page/headers/MobileHeader.vue b/src/components/viewport/page/headers/MobileHeader.vue
index 13d3c4c7..f10c5ff9 100644
--- a/src/components/viewport/page/headers/MobileHeader.vue
+++ b/src/components/viewport/page/headers/MobileHeader.vue
@@ -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);
diff --git a/tests/e2e/mobile-header-safe-area.spec.ts b/tests/e2e/mobile-header-safe-area.spec.ts
new file mode 100644
index 00000000..0b01f9ae
--- /dev/null
+++ b/tests/e2e/mobile-header-safe-area.spec.ts
@@ -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);
+});
diff --git a/tests/unit/mobile-header-safe-area.spec.js b/tests/unit/mobile-header-safe-area.spec.js
new file mode 100644
index 00000000..9c5a0e8f
--- /dev/null
+++ b/tests/unit/mobile-header-safe-area.spec.js
@@ -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");
+ });
+});