diff --git a/apps/web/src/components/map/SearchInAreaChip.tsx b/apps/web/src/components/map/SearchInAreaChip.tsx index 82d4ede47..040ed6e1d 100644 --- a/apps/web/src/components/map/SearchInAreaChip.tsx +++ b/apps/web/src/components/map/SearchInAreaChip.tsx @@ -12,7 +12,7 @@ import { useTranslations } from "next-intl"; import { useMemo } from "react"; import { useMap } from "@/integration-api/map/MapContext"; import { BRAND } from "@/integration-api/runtime/theme"; -import { PANEL_WIDTH } from "@/lib/layout"; +import { PANEL_WIDTH, SEARCH_BAR_INSET, topInset } from "@/lib/layout"; // Floating card: sidebar(400) + gap(24) + card(376) = 800px from left edge const FLOATING_CARD_RIGHT_EDGE = 800; @@ -83,10 +83,11 @@ export function SearchInAreaChip() { sx={{ position: "absolute", // Below the category chips row: - // Desktop: chips at top=18, height=40px → top=66; Mobile: chips at top=72 → top=120 + // Desktop: chips 6px below the bar's top, 40px tall, then 8px → 54px; + // mobile: chips 60px below it → 108px. top: { - xs: "calc(120px + var(--omx-safe-top))", - sm: "calc(66px + var(--omx-safe-top))", + xs: topInset(SEARCH_BAR_INSET, 108), + sm: topInset(SEARCH_BAR_INSET, 54), }, // When the floating card is open, center in the remaining map area to its right. // Remaining area: FLOATING_CARD_RIGHT_EDGE → 100vw, center = (edge + 100%) / 2 diff --git a/apps/web/src/components/map/TopRightControls.tsx b/apps/web/src/components/map/TopRightControls.tsx index cd4e53558..eb45d9033 100644 --- a/apps/web/src/components/map/TopRightControls.tsx +++ b/apps/web/src/components/map/TopRightControls.tsx @@ -2,6 +2,7 @@ import Box from "@mui/material/Box"; import { AccountAvatarButton } from "@/components/auth/AccountAvatarButton"; +import { SEARCH_BAR_TOP } from "@/lib/layout"; /** * Desktop-only floating account avatar at the top-right of the map. On mobile @@ -14,7 +15,7 @@ export function TopRightControls() { sx={{ display: { xs: "none", sm: "flex" }, position: "absolute", - top: "calc(12px + var(--omx-safe-top))", + top: SEARCH_BAR_TOP, right: "calc(12px + var(--omx-safe-right))", alignItems: "center", gap: 1, diff --git a/apps/web/src/components/map/street-level-imagery/StreetLevelViewerInner.tsx b/apps/web/src/components/map/street-level-imagery/StreetLevelViewerInner.tsx index d04476344..179f2c0d1 100644 --- a/apps/web/src/components/map/street-level-imagery/StreetLevelViewerInner.tsx +++ b/apps/web/src/components/map/street-level-imagery/StreetLevelViewerInner.tsx @@ -20,7 +20,7 @@ import { LocationMinimap } from "@/integration-api/components/LocationMinimap"; import { useStreetLevelProviders } from "@/integration-api/components/useStreetLevelProviders"; import { useMap } from "@/integration-api/map/MapContext"; import { useEnv } from "@/integration-api/runtime/EnvProvider"; -import { SEARCH_BAR_CLEARANCE } from "@/lib/layout"; +import { SEARCH_BAR_CLEARANCE, topInset } from "@/lib/layout"; import { StreetLevelFlatImage } from "./StreetLevelFlatImage"; import { StreetLevelInfoCard } from "./StreetLevelInfoCard"; import { fetchStreetLevelNode, pickPanoramaUrl, type StreetLevelNode } from "./useStreetLevelNode"; @@ -352,7 +352,7 @@ export default function StreetLevelViewerInner() { position: "absolute", // On a phone the search bar spans the top, so the button goes below // it; beside it on wider screens. Both clear the status bar. - top: { xs: SEARCH_BAR_CLEARANCE, sm: "calc(8px + var(--omx-safe-top))" }, + top: { xs: SEARCH_BAR_CLEARANCE, sm: topInset(8) }, right: "calc(8px + var(--omx-safe-right))", bgcolor: "rgba(0,0,0,0.5)", color: "#fff", diff --git a/apps/web/src/components/navigation/GroundNavigationChrome.tsx b/apps/web/src/components/navigation/GroundNavigationChrome.tsx index b50ed1454..5864865e9 100644 --- a/apps/web/src/components/navigation/GroundNavigationChrome.tsx +++ b/apps/web/src/components/navigation/GroundNavigationChrome.tsx @@ -20,7 +20,7 @@ import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { NavigationSettingsDialog } from "@/components/settings/NavigationSettingsDialog"; import { useMapOptional } from "@/integration-api/map/MapContext"; -import { NAV_CHROME_INSET, NAV_LANDSCAPE_PANEL_WIDTH } from "@/lib/layout"; +import { NAV_CHROME_TOP, NAV_LANDSCAPE_PANEL_WIDTH } from "@/lib/layout"; import { useNavigationMutations } from "@/lib/mobile/useNavigationMutations"; import { useMobilePanelClearance, @@ -217,9 +217,9 @@ export function GroundNavigationChrome({ coverage }: Props) { flexDirection: "column", gap: 1, p: 2, - // Match the 16px (`p: 2`) side inset below the safe-area top so the - // banner's gap to the top equals its gap to the sides. - pt: `calc(var(--omx-safe-top) + ${NAV_CHROME_INSET}px)`, + // The 16px (`p: 2`) side inset at the top of a browser tab; under + // a status bar the banner sits closer, as the search bar does. + pt: NAV_CHROME_TOP, // On wide screens keep the banner in a left-hand column instead of // stretching across the whole top edge (maxWidth adds back the 16px // horizontal padding so the card itself is LANDSCAPE_PANEL_WIDTH). diff --git a/apps/web/src/components/navigation/RouteSearchControl.test.tsx b/apps/web/src/components/navigation/RouteSearchControl.test.tsx index e030bdd83..f73775121 100644 --- a/apps/web/src/components/navigation/RouteSearchControl.test.tsx +++ b/apps/web/src/components/navigation/RouteSearchControl.test.tsx @@ -43,6 +43,7 @@ vi.mock("./RouteSearchResultsLayer", () => ({ ), })); +import { NAV_CHROME_TOP } from "@/lib/layout"; import { useRouteSearchStore } from "@/lib/navigation/routeSearchStore"; import { RouteSearchControl } from "./RouteSearchControl"; @@ -258,7 +259,7 @@ it("lines the picker and stop card up with the maneuver banner and bottom bar in useRouteSearchStore.getState().openPicker(); const { unmount } = render(view()); const picker = screen.getByText("Search along route").closest(".MuiPaper-root") as HTMLElement; - expect(getComputedStyle(picker).top).toBe("calc(var(--omx-safe-top) + 16px)"); + expect(getComputedStyle(picker).top).toBe(NAV_CHROME_TOP); unmount(); useRouteSearchStore.getState().setCategoryKey("preset:amenity/fuel"); render(view()); diff --git a/apps/web/src/components/navigation/RouteSearchControl.tsx b/apps/web/src/components/navigation/RouteSearchControl.tsx index 8b0ec1abb..ae4d3a1fe 100644 --- a/apps/web/src/components/navigation/RouteSearchControl.tsx +++ b/apps/web/src/components/navigation/RouteSearchControl.tsx @@ -36,7 +36,7 @@ import { BrandLogo } from "@/components/search/BrandLogo"; import { AttributionStrip } from "@/components/ui/AttributionStrip"; import { useMapOptional } from "@/integration-api/map/MapContext"; import { BRAND } from "@/integration-api/runtime/theme"; -import { NAV_CHROME_INSET } from "@/lib/layout"; +import { NAV_CHROME_INSET, NAV_CHROME_TOP } from "@/lib/layout"; import { useRouteSearchStore } from "@/lib/navigation/routeSearchStore"; import { routeStopCopy } from "@/lib/navigation/routeStopCopy"; import { routeSearchQueryFor, useRouteSearch } from "@/lib/navigation/useRouteSearch"; @@ -287,7 +287,7 @@ export function RouteSearchControl() { position: "fixed", left: "50%", transform: "translateX(-50%)", - top: `calc(var(--omx-safe-top) + ${NAV_CHROME_INSET}px)`, + top: NAV_CHROME_TOP, width: { xs: `calc(100% - ${2 * NAV_CHROME_INSET}px)`, sm: 480 }, borderRadius: 3, zIndex: 1350, diff --git a/apps/web/src/components/navigation/TransitNavigationView.tsx b/apps/web/src/components/navigation/TransitNavigationView.tsx index 9cf2e5632..e6aa48c5b 100644 --- a/apps/web/src/components/navigation/TransitNavigationView.tsx +++ b/apps/web/src/components/navigation/TransitNavigationView.tsx @@ -14,7 +14,7 @@ import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { NavigationSettingsDialog } from "@/components/settings/NavigationSettingsDialog"; import { useMapOptional } from "@/integration-api/map/MapContext"; -import { NAV_LANDSCAPE_PANEL_WIDTH } from "@/lib/layout"; +import { NAV_CHROME_TOP, NAV_LANDSCAPE_PANEL_WIDTH } from "@/lib/layout"; import { useMobileRuntime } from "@/lib/mobile/useMobileRuntime"; import { useNavigationMutations } from "@/lib/mobile/useNavigationMutations"; import { useNavChromeObstructions } from "@/lib/navigation/useNavChromeObstructions"; @@ -158,9 +158,8 @@ export function TransitNavigationView() { flexDirection: "column", gap: 1, p: 2, - // Match the driving banner's inset below the safe-area top so the - // gap to the top equals the gap to the sides. - pt: "calc(var(--omx-safe-top) + 16px)", + // Same top as the driving banner. + pt: NAV_CHROME_TOP, // On wide screens keep the chrome in a left-hand column (like the // driving view) so the map stays clear on the right. ...(isMobile diff --git a/apps/web/src/components/panels/DetailShell.tsx b/apps/web/src/components/panels/DetailShell.tsx index 7a1e8c582..4677e7169 100644 --- a/apps/web/src/components/panels/DetailShell.tsx +++ b/apps/web/src/components/panels/DetailShell.tsx @@ -6,7 +6,7 @@ import useMediaQuery from "@mui/material/useMediaQuery"; import { PANEL, useNavigationStore, useSidebarStore } from "@openmapx/core"; import { useTranslations } from "next-intl"; import { lazy, type ReactNode, Suspense, useContext, useEffect, useState } from "react"; -import { NAV_LANDSCAPE_PANEL_WIDTH, PANEL_WIDTH } from "@/lib/layout"; +import { NAV_CHROME_TOP, NAV_LANDSCAPE_PANEL_WIDTH, PANEL_WIDTH } from "@/lib/layout"; import { useMapObstruction } from "@/lib/mapObstructions"; import { useMobilePanelHeightTracker } from "@/lib/mobilePanelHeight"; import { PLACE_DETENTS } from "./sheet/detents"; @@ -82,7 +82,7 @@ function DesktopDetail({ children }: { children: ReactNode }) { // While navigating the card sits beside the guidance column instead; the // nav camera keeps its own framing, so it claims no map obstruction. const left = navigating ? NAV_LANDSCAPE_PANEL_WIDTH + 2 * CARD_GAP : PANEL_WIDTH + CARD_GAP; - const top = navigating ? "calc(var(--omx-safe-top) + 16px)" : "66px"; + const top = navigating ? NAV_CHROME_TOP : "66px"; useMapObstruction( "detail", "left", diff --git a/apps/web/src/components/search/SearchBar.tsx b/apps/web/src/components/search/SearchBar.tsx index 3bfd6ef1a..1662432ba 100644 --- a/apps/web/src/components/search/SearchBar.tsx +++ b/apps/web/src/components/search/SearchBar.tsx @@ -93,7 +93,12 @@ import { launchExploreTextSearch, launchTextSearch, } from "@/lib/launchExplore"; -import { SEARCH_BAR_CLEARANCE, SEARCH_BAR_HEIGHT, SEARCH_BAR_INSET } from "@/lib/layout"; +import { + SEARCH_BAR_CLEARANCE, + SEARCH_BAR_HEIGHT, + SEARCH_BAR_INSET, + SEARCH_BAR_TOP, +} from "@/lib/layout"; import { useMeasuredMapObstruction } from "@/lib/mapObstructions"; import { servedAttributions } from "@/lib/servedAttributions"; import { useBackDismiss } from "@/lib/useBackDismiss"; @@ -1029,7 +1034,7 @@ export function SearchBar({ surface = "map" }: SearchBarProps) { onBlurCapture={handleBlur} sx={{ position: "absolute", - top: `calc(${SEARCH_BAR_INSET}px + var(--omx-safe-top))`, + top: SEARCH_BAR_TOP, left: `calc(${SEARCH_BAR_INSET}px + var(--omx-safe-left))`, right: { xs: `calc(${SEARCH_BAR_INSET}px + var(--omx-safe-right))`, diff --git a/apps/web/src/components/search/floatingChipSx.ts b/apps/web/src/components/search/floatingChipSx.ts index 962ec00e1..9a1b03745 100644 --- a/apps/web/src/components/search/floatingChipSx.ts +++ b/apps/web/src/components/search/floatingChipSx.ts @@ -1,5 +1,6 @@ import type { SxProps, Theme } from "@mui/material/styles"; import { BRAND } from "@/integration-api/runtime/theme"; +import { SEARCH_BAR_HEIGHT, SEARCH_BAR_INSET, topInset } from "@/lib/layout"; /** * Shared "floating pill chip" styling used by the map-overlay chip rows @@ -59,8 +60,9 @@ export function floatingChipSx(active: boolean, variant: "category" | "toggle"): export const floatingToolbarSx = { position: "absolute", top: { - xs: "calc(72px + var(--omx-safe-top))", - sm: "calc(18px + var(--omx-safe-top))", + // Below the search bar on a phone; level with its centre beside it. + xs: topInset(SEARCH_BAR_INSET, SEARCH_BAR_HEIGHT + SEARCH_BAR_INSET), + sm: topInset(SEARCH_BAR_INSET, 6), }, left: { xs: "var(--omx-safe-left)", sm: "calc(420px + var(--omx-safe-left))" }, right: { xs: "var(--omx-safe-right)", sm: "calc(108px + var(--omx-safe-right))" }, diff --git a/apps/web/src/components/weather/WeatherWidget.tsx b/apps/web/src/components/weather/WeatherWidget.tsx index 8bc7550fe..c5d11799b 100644 --- a/apps/web/src/components/weather/WeatherWidget.tsx +++ b/apps/web/src/components/weather/WeatherWidget.tsx @@ -17,6 +17,7 @@ import { WeatherIcon } from "@/integration-api/components/WeatherIcon"; import { useMap } from "@/integration-api/map/MapContext"; import { useContributorResolver } from "@/integration-api/overlay/useContributorResolver"; import { useMapAttributions } from "@/integration-api/overlay/useMapAttributions"; +import { SEARCH_BAR_INSET, topInset } from "@/lib/layout"; import { windDirectionLabel } from "./weatherUtils"; export function WeatherWidget() { @@ -80,8 +81,8 @@ export function WeatherWidget() { sx={{ position: "absolute", top: { - xs: "calc(118px + var(--omx-safe-top))", - sm: "calc(74px + var(--omx-safe-top))", + xs: topInset(SEARCH_BAR_INSET, 106), + sm: topInset(SEARCH_BAR_INSET, 62), }, left: "calc(12px + var(--omx-safe-left))", zIndex: 9, diff --git a/apps/web/src/lib/layout.test.ts b/apps/web/src/lib/layout.test.ts index 2be1fb61b..283f7355d 100644 --- a/apps/web/src/lib/layout.test.ts +++ b/apps/web/src/lib/layout.test.ts @@ -1,5 +1,28 @@ import { describe, expect, it } from "vitest"; -import { isPanelShiftActive, shouldHideLayerSelector } from "./layout"; +import { + isPanelShiftActive, + SEARCH_BAR_CLEARANCE, + SEARCH_BAR_TOP, + shouldHideLayerSelector, + topInset, +} from "./layout"; + +describe("topInset", () => { + it("keeps the page's edge spacing without a status bar and 8px below one", () => { + // The status bar already separates the chrome from the screen edge, so + // the installed app leaves Google Maps' 8px under it, not edge + inset. + expect(topInset(12)).toBe("max(12px, var(--omx-safe-top) + 8px)"); + }); + + it("stacks rows below that top", () => { + expect(topInset(12, 60)).toBe("calc(max(12px, var(--omx-safe-top) + 8px) + 60px)"); + }); + + it("clears the search bar by its height plus the same 12px below it", () => { + expect(SEARCH_BAR_TOP).toBe(topInset(12)); + expect(SEARCH_BAR_CLEARANCE).toBe(topInset(12, 60)); + }); +}); describe("isPanelShiftActive", () => { it("is active when the sidebar is open, expanded, and not navigating", () => { diff --git a/apps/web/src/lib/layout.ts b/apps/web/src/lib/layout.ts index d15651b1f..7d868da4b 100644 --- a/apps/web/src/lib/layout.ts +++ b/apps/web/src/lib/layout.ts @@ -14,17 +14,41 @@ export const SAFE_AREA_PADDING = { } as const; export const PANEL_WIDTH = 400; +/** + * Gap between the status bar (or a display cutout) and the chrome floating + * below it, the same 8px Google Maps leaves above its search bar on Android. + * The status bar already sets the chrome apart from the screen edge, so the + * page's own edge spacing on top of it reads as wasted space. + */ +export const STATUS_BAR_GAP = 8; + +/** + * Top offset for chrome that floats `edge` px below the top of the page. Under + * a status bar (the installed app, an installed web app) the chrome sits + * `STATUS_BAR_GAP` below the bar instead; in a browser tab the inset is 0 and + * `edge` applies. `below` adds the height of the rows stacked above it. + */ +export function topInset(edge: number, below = 0): string { + const base = `max(${edge}px, var(--omx-safe-top) + ${STATUS_BAR_GAP}px)`; + return below ? `calc(${base} + ${below}px)` : base; +} + /** Offset of the floating search bar from the top and left edges of the map. */ export const SEARCH_BAR_INSET = 12; /** Height of the floating search bar's input row. */ export const SEARCH_BAR_HEIGHT = 48; +/** Top of the floating search bar. */ +export const SEARCH_BAR_TOP = topInset(SEARCH_BAR_INSET); /** * Top strip of the desktop sidebar that the floating search bar covers, plus - * the same breathing room below the bar as above it. Panels keep their content - * out of this strip; see `searchBarClearance` in the panels directory. + * `SEARCH_BAR_INSET` of breathing room below the bar. Panels keep their + * content out of this strip; see `searchBarClearance` in the panels directory. */ -export const SEARCH_BAR_CLEARANCE = `calc(${SEARCH_BAR_INSET * 2 + SEARCH_BAR_HEIGHT}px + var(--omx-safe-top))`; +export const SEARCH_BAR_CLEARANCE = topInset( + SEARCH_BAR_INSET, + SEARCH_BAR_HEIGHT + SEARCH_BAR_INSET, +); /** * Width of the left-hand nav-chrome column on wide viewports (desktop and @@ -40,6 +64,8 @@ export const NAV_LANDSCAPE_PANEL_WIDTH = 400; * search-along-route picker and stop card) use it too, so they line up. */ export const NAV_CHROME_INSET = 16; +/** Top of the turn-by-turn chrome and the overlays that line up with it. */ +export const NAV_CHROME_TOP = topInset(NAV_CHROME_INSET); /** * Whether the left sidebar panel is currently occupying horizontal space, so