mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
fix(app): respect iOS PWA safe areas
This commit is contained in:
@@ -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")
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user