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"); + }); +});