fix(app): respect iOS PWA safe areas

This commit is contained in:
Hona
2026-09-20 04:48:39 +00:00
parent a1956a7522
commit bef9a4d805
3 changed files with 121 additions and 6 deletions
@@ -0,0 +1,104 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
const cases = [
{
name: "mobile browser",
viewport: { width: 390, height: 844 },
insets: { top: 47, right: 0, bottom: 34, left: 0 },
standalone: false,
bottom: false,
},
{
name: "iOS standalone",
viewport: { width: 390, height: 844 },
insets: { top: 47, right: 0, bottom: 34, left: 0 },
standalone: true,
bottom: false,
},
{
name: "iOS standalone landscape",
viewport: { width: 844, height: 390 },
insets: { top: 0, right: 47, bottom: 21, left: 47 },
standalone: true,
bottom: false,
},
{
name: "iOS standalone with bottom tabs",
viewport: { width: 390, height: 844 },
insets: { top: 47, right: 0, bottom: 34, left: 0 },
standalone: true,
bottom: true,
},
]
for (const input of cases) {
test.describe(input.name, () => {
test.use({ viewport: input.viewport, hasTouch: true })
test("keeps app navigation clear of native chrome", async ({ page }) => {
const cdp = await page.context().newCDPSession(page)
await cdp.send("Emulation.setSafeAreaInsetsOverride", { insets: input.insets })
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
provider: fixture.provider,
sessions: fixture.sessions,
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ bottom, directory, server, sessions, standalone }) => {
Object.defineProperty(navigator, "standalone", { value: standalone })
localStorage.setItem(
"settings.v3",
JSON.stringify({ general: { mobileTitlebarPosition: bottom ? "bottom" : "top" } }),
)
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(sessions.map((session) => ({ type: "session", server, sessionId: session.id }))),
)
},
{
bottom: input.bottom,
directory: fixture.directory,
server: fixture.serverKey,
sessions: fixture.sessions,
standalone: input.standalone,
},
)
await page.goto("/")
const titlebar = page.locator('[data-slot="titlebar-v2"]')
const top = input.insets.top + (input.standalone ? 32 : 0)
await expect(titlebar).toHaveCSS("padding-left", `${input.insets.left}px`)
await expect(titlebar).toHaveCSS("padding-right", `${input.insets.right}px`)
if (input.bottom) {
await expect(titlebar).toHaveCSS("padding-bottom", `${input.insets.bottom}px`)
await expect(page.getByRole("main")).toHaveCSS("padding-top", `${top}px`)
} else {
await expect(titlebar).toHaveCSS("padding-top", `${top}px`)
}
const navigation =
input.viewport.width < 768
? titlebar.getByRole("button", { name: "Tabs", exact: true })
: titlebar.getByRole("button", { name: "Home", exact: true })
await expect(navigation).toBeInViewport({ ratio: 1 })
const bounds = await navigation.boundingBox()
expect(bounds).not.toBeNull()
expect(bounds!.x).toBeGreaterThanOrEqual(input.insets.left)
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(input.viewport.width - input.insets.right)
if (input.viewport.width >= 768) return
await navigation.tap()
await expect(navigation).toHaveAttribute("aria-expanded", "true")
})
})
}
+7 -2
View File
@@ -31,6 +31,7 @@ export default function Layout(props: ParentProps) {
})
const verticalTabs = () => preferences.appearance.tabLayout() === "vertical" && !mobile()
const bottomTitlebar = () => mobile() && preferences.general.mobileTitlebarPosition() === "bottom"
const iosStandalone = platform.platform === "web" && "standalone" in navigator && navigator.standalone === true
const update: TitlebarUpdate = {
get state() {
@@ -60,9 +61,13 @@ export default function Layout(props: ParentProps) {
<div
class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"
style={{
// iOS draws a native status-bar fade beyond the reported device inset in standalone mode.
"--safe-area-inset-top": iosStandalone
? "calc(env(safe-area-inset-top, 0px) + 32px)"
: "env(safe-area-inset-top, 0px)",
// Native Windows chrome supplies the gap; retain paint clearance for the panels' outer outlines.
"--shell-top-inset": bottomTitlebar()
? "max(0px, calc(8px - env(safe-area-inset-top, 0px)))"
? "max(0px, calc(8px - var(--safe-area-inset-top, env(safe-area-inset-top, 0px))))"
: platform.platform === "desktop" && platform.os === "windows"
? "1px"
: "8px",
@@ -101,7 +106,7 @@ export default function Layout(props: ParentProps) {
<main
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-content"
style={{
"padding-top": bottomTitlebar() ? "env(safe-area-inset-top, 0px)" : "0px",
"padding-top": bottomTitlebar() ? "var(--safe-area-inset-top, env(safe-area-inset-top, 0px))" : "0px",
"padding-bottom":
bottomTitlebar() || settings.active()
? "0px"
+10 -4
View File
@@ -158,13 +158,18 @@ export function Titlebar(props: {
platform.platform === "web"
? bottom()
? "calc(28px + max(8px, var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))))"
: "calc(28px + max(8px, env(safe-area-inset-top, 0px)))"
: "calc(28px + max(8px, var(--safe-area-inset-top, env(safe-area-inset-top, 0px))))"
: undefined,
"padding-top": bottom() ? "0px" : "env(safe-area-inset-top, 0px)",
"padding-top": bottom() ? "0px" : "var(--safe-area-inset-top, env(safe-area-inset-top, 0px))",
"padding-bottom": bottom() ? "var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))" : "0px",
"min-height": minHeight(),
// Keep native macOS traffic lights clear even when the desktop window is narrow.
"padding-left": macTrafficLights() ? `${macTrafficLightsBaseWidth / zoom()}px` : 0,
"padding-left": macTrafficLights()
? `${macTrafficLightsBaseWidth / zoom()}px`
: platform.platform === "web"
? "env(safe-area-inset-left, 0px)"
: 0,
"padding-right": platform.platform === "web" ? "env(safe-area-inset-right, 0px)" : 0,
width: windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
"max-width": windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
// Native Windows caption controls remain on the physical right in both writing directions.
@@ -441,7 +446,8 @@ export function Titlebar(props: {
<div
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 px-2 md:pe-3"
classList={{
"pt-[max(0px,calc(8px-env(safe-area-inset-top,0px)))]": !bottom() && !windows(),
"pt-[max(0px,calc(8px-var(--safe-area-inset-top,env(safe-area-inset-top,0px))))]":
!bottom() && !windows(),
"pb-[max(0px,calc(8px-var(--safe-area-inset-bottom,env(safe-area-inset-bottom,0px))))]": bottom(),
"pl-4": macTrafficLights(),
// Center the 20px app icon over the sidebar's 16px icon column.