diff --git a/apps/web/src/app/providers.tsx b/apps/web/src/app/providers.tsx index f2f2fa4bd..31c3f7bb5 100644 --- a/apps/web/src/app/providers.tsx +++ b/apps/web/src/app/providers.tsx @@ -103,6 +103,44 @@ const theme = createTheme({ }, }, }, + // The installed app draws the page under the status and navigation bars + // (`viewport-fit=cover`), so every dialog and drawer keeps its content out + // of them. In a plain browser tab the insets are 0. + MuiDialog: { + styleOverrides: { + // A floating dialog is centred and sized within the safe area. + container: ({ ownerState }) => + ownerState.fullScreen + ? {} + : { + boxSizing: "border-box", + paddingTop: "var(--omx-safe-top)", + paddingBottom: "var(--omx-safe-bottom)", + paddingLeft: "var(--omx-safe-left)", + paddingRight: "var(--omx-safe-right)", + }, + // A full-screen dialog still covers the bars, with its content inset. + paperFullScreen: { + boxSizing: "border-box", + paddingTop: "var(--omx-safe-top)", + paddingBottom: "var(--omx-safe-bottom)", + paddingLeft: "var(--omx-safe-left)", + paddingRight: "var(--omx-safe-right)", + }, + }, + }, + MuiDrawer: { + styleOverrides: { + // The screen edge the drawer is attached to and the two beside it; + // its open side faces the page, not a system bar. + paper: ({ ownerState }) => ({ + ...(ownerState.anchor !== "bottom" && { paddingTop: "var(--omx-safe-top)" }), + ...(ownerState.anchor !== "top" && { paddingBottom: "var(--omx-safe-bottom)" }), + ...(ownerState.anchor !== "right" && { paddingLeft: "var(--omx-safe-left)" }), + ...(ownerState.anchor !== "left" && { paddingRight: "var(--omx-safe-right)" }), + }), + }, + }, }, }); diff --git a/apps/web/src/components/admin/AdminLayout.tsx b/apps/web/src/components/admin/AdminLayout.tsx index 385634bfb..9a8a8653d 100644 --- a/apps/web/src/components/admin/AdminLayout.tsx +++ b/apps/web/src/components/admin/AdminLayout.tsx @@ -57,7 +57,7 @@ export function AdminLayout({ children, user, selfHosted = false }: AdminLayoutP diff --git a/apps/web/src/components/admin/AdminTopBar.tsx b/apps/web/src/components/admin/AdminTopBar.tsx index dbbc8f6af..a80270846 100644 --- a/apps/web/src/components/admin/AdminTopBar.tsx +++ b/apps/web/src/components/admin/AdminTopBar.tsx @@ -69,6 +69,7 @@ export function AdminTopBar({ user, sidebarOpen, onToggleSidebar }: AdminTopBarP borderBottom: "1px solid", borderColor: "divider", color: "text.primary", + pt: "var(--omx-safe-top)", width: { md: sidebarOpen ? `calc(100% - ${SIDEBAR_WIDTH}px)` : "100%" }, ml: { md: sidebarOpen ? `${SIDEBAR_WIDTH}px` : 0 }, transition: diff --git a/apps/web/src/components/admin/ImpersonationBanner.tsx b/apps/web/src/components/admin/ImpersonationBanner.tsx index f983ca002..50c321e7b 100644 --- a/apps/web/src/components/admin/ImpersonationBanner.tsx +++ b/apps/web/src/components/admin/ImpersonationBanner.tsx @@ -37,7 +37,9 @@ export function ImpersonationBanner() { left: 0, right: 0, zIndex: 9999, - height: 48, + boxSizing: "border-box", + height: "calc(48px + var(--omx-safe-top))", + pt: "calc(6px + var(--omx-safe-top))", "& .MuiAlert-message": { display: "flex", alignItems: "center", @@ -61,7 +63,7 @@ export function ImpersonationBanner() { Impersonating {session.user.name} ({session.user.email}) {/* Spacer to push page content below the fixed banner */} -
+
); } diff --git a/apps/web/src/components/map/street-level-imagery/StreetLevelInfoCard.tsx b/apps/web/src/components/map/street-level-imagery/StreetLevelInfoCard.tsx index 5a5e387ac..a2a5a42b0 100644 --- a/apps/web/src/components/map/street-level-imagery/StreetLevelInfoCard.tsx +++ b/apps/web/src/components/map/street-level-imagery/StreetLevelInfoCard.tsx @@ -24,6 +24,7 @@ import { useEffect, useRef, useState } from "react"; import { AttributionStrip } from "@/components/ui/AttributionStrip"; import { AttributionText } from "@/components/ui/AttributionText"; import { useMap } from "@/integration-api/map/MapContext"; +import { SEARCH_BAR_CLEARANCE } from "@/lib/layout"; /** Capture month and year in the active locale, or null when unknown. */ export function formatCaptureDate(capturedAt: string | undefined, locale: string): string | null { @@ -98,14 +99,20 @@ export function StreetLevelInfoCard({ {/* Top bar */} diff --git a/apps/web/src/components/panels/sheet/detents.ts b/apps/web/src/components/panels/sheet/detents.ts index 7a9fac922..60633b752 100644 --- a/apps/web/src/components/panels/sheet/detents.ts +++ b/apps/web/src/components/panels/sheet/detents.ts @@ -31,7 +31,9 @@ export interface SnapSlot { detent: "top" | Detent; } -const MAX_HEIGHT = "94dvh"; +// Never under the status bar, whose height `dvh` does not know about when the +// page is drawn beneath it. +const MAX_HEIGHT = "min(94dvh, calc(100dvh - var(--omx-safe-top) - 8px))"; export const PLACE_DETENTS: DetentConfig = { peek: "180px", diff --git a/apps/web/src/integration-api/runtime/useFullScreenOnMobile.ts b/apps/web/src/integration-api/runtime/useFullScreenOnMobile.ts index 0af60a809..ea9184c9c 100644 --- a/apps/web/src/integration-api/runtime/useFullScreenOnMobile.ts +++ b/apps/web/src/integration-api/runtime/useFullScreenOnMobile.ts @@ -15,9 +15,10 @@ import type { SystemStyleObject } from "@mui/system/styleFunctionSx"; * slotProps={{ paper: { sx: mobileFullScreenDialogPaperSx } }} * /> * - * The shared paper sx squares off the corners on mobile, keeps the content - * clear of the system bars, and vertically centers DialogContent when its children don't fill the viewport (using - * `safe center` so long, scrollable content still starts at the top). + * The shared paper sx squares off the corners on mobile and vertically centers + * DialogContent when its children don't fill the viewport (using `safe center` + * so long, scrollable content still starts at the top). The theme keeps every + * full-screen dialog's content clear of the system bars. */ export function useFullScreenOnMobile(): boolean { const theme = useTheme(); @@ -26,11 +27,6 @@ export function useFullScreenOnMobile(): boolean { export const mobileFullScreenDialogPaperSx: SystemStyleObject = { borderRadius: { xs: 0, sm: "12px" }, - // A full-screen dialog covers the status bar and home indicator wherever the - // page draws edge to edge (an installed PWA, the installed app), so its - // content keeps clear of the system bars there. - paddingTop: { xs: "var(--omx-safe-top)", sm: 0 }, - paddingBottom: { xs: "var(--omx-safe-bottom)", sm: 0 }, "& .MuiDialogContent-root": { display: { xs: "flex", sm: "block" }, flexDirection: "column", diff --git a/apps/web/src/lib/layout.ts b/apps/web/src/lib/layout.ts index 31d89e66e..d15651b1f 100644 --- a/apps/web/src/lib/layout.ts +++ b/apps/web/src/lib/layout.ts @@ -1,4 +1,17 @@ /** Shared layout constants for the sidebar panels. */ + +/** + * Padding that keeps a full-screen surface's content out of the system bars + * and display cutouts. The installed app and an installed web app draw the + * page under them (`viewport-fit=cover`); in a plain browser tab the insets + * are 0, so this changes nothing there. + */ +export const SAFE_AREA_PADDING = { + pt: "var(--omx-safe-top)", + pb: "var(--omx-safe-bottom)", + pl: "var(--omx-safe-left)", + pr: "var(--omx-safe-right)", +} as const; export const PANEL_WIDTH = 400; /** Offset of the floating search bar from the top and left edges of the map. */