From e0f545fed485d244fcd3e6308c36e5a6397eaa2e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 9 Nov 2026 22:03:21 +0700 Subject: [PATCH] fix(app): use the iPhone Duo top band beside the status region (#8901) On the iPhone Duo inner screen in portrait, the workspace header, the left sidebar's first row and the explorer tab rail now use the top band beside the status region. The status region is published as the window's top-right obstruction, the same slot desktop window controls use, so the row owning that corner pads past it. Content gains 56pt. Refs #7924 Co-authored-by: Elsa Granger --- .../ios/PaseoWindowLayoutModule.swift | 16 +++ .../components/attachment-lightbox.test.tsx | 1 + .../src/components/headers/compact-header.tsx | 23 ++- packages/app/src/components/left-sidebar.tsx | 10 +- .../screens/workspace/workspace-screen.tsx | 37 ++++- packages/app/src/utils/desktop-window.test.ts | 45 +++++- packages/app/src/utils/desktop-window.tsx | 10 +- packages/app/src/window-layout/context.ts | 10 +- packages/app/src/window-layout/index.ios.tsx | 22 ++- packages/app/src/window-layout/index.tsx | 3 +- packages/app/src/window-layout/model.test.ts | 132 +++++++++++++++++- packages/app/src/window-layout/model.ts | 48 ++++++- 12 files changed, 318 insertions(+), 39 deletions(-) diff --git a/packages/app/modules/paseo-window-layout/ios/PaseoWindowLayoutModule.swift b/packages/app/modules/paseo-window-layout/ios/PaseoWindowLayoutModule.swift index 3ac90cf2b5..2168daf5b6 100644 --- a/packages/app/modules/paseo-window-layout/ios/PaseoWindowLayoutModule.swift +++ b/packages/app/modules/paseo-window-layout/ios/PaseoWindowLayoutModule.swift @@ -18,6 +18,8 @@ final class WindowLayoutView: ExpoView { private var barGuide: UILayoutGuide? private var barEdge = "none" private var bottomBarGuide: UILayoutGuide? + private var topBarGuide: UILayoutGuide? + private var occlusions: [CGRect] = [] private var measuredBounds = CGRect.zero required init(appContext: AppContext? = nil) { @@ -38,6 +40,7 @@ final class WindowLayoutView: ExpoView { super.didMoveToWindow() previousPayload = nil bottomBarGuide = nil + topBarGuide = nil setNeedsUpdateConstraints() scheduleUpdate() } @@ -62,6 +65,8 @@ final class WindowLayoutView: ExpoView { if #available(iOS 27.1, *) { // UIKit tracks these reads and invalidates constraints when regions change. divisions = reservedRegions(kind: .division).compactMap { $0.isActive ? $0.frame : nil } + // Frames already include the system's interactive margins. + occlusions = reservedRegions(kind: .occlusion).compactMap { $0.isActive ? $0.frame : nil } let edge: String switch traitCollection.verticalBarEdge { case .leading: edge = "leading" @@ -73,6 +78,11 @@ final class WindowLayoutView: ExpoView { // screen's corners rather than the rectangular safe area. bottomBarGuide = layoutGuide(for: .bar(onEdge: UIRectEdge.bottom, extent: 44)) } + if topBarGuide == nil { + // Where a top bar rests. Beside a partial status region it can start above the + // rectangular safe area; content still keeps out of the occlusions. + topBarGuide = layoutGuide(for: .bar(onEdge: UIRectEdge.top, extent: 44)) + } if edge != barEdge { barEdge = edge barGuide = edge == "none" ? nil : layoutGuide( @@ -106,11 +116,17 @@ final class WindowLayoutView: ExpoView { if let bottomBar = bottomBarGuide?.layoutFrame, bottomBar.height > 0 { bottomClearance = max(0, bounds.maxY - bottomBar.maxY) } + var topBar: Any = NSNull() + if let frame = topBarGuide?.layoutFrame, frame.width > 0, frame.height > 0 { + topBar = rect(frame) + } let payload: [String: Any] = [ "bounds": rect(bounds), "verticalBarEdge": barEdge, "verticalBar": barPayload, "bottomClearance": bottomClearance, + "topBar": topBar, + "occlusions": occlusions.map(rect), "divisions": divisions.map(rect) ] guard !NSDictionary(dictionary: payload).isEqual(to: previousPayload ?? [:]) else { return } diff --git a/packages/app/src/components/attachment-lightbox.test.tsx b/packages/app/src/components/attachment-lightbox.test.tsx index 82fe6b139f..4920195258 100644 --- a/packages/app/src/components/attachment-lightbox.test.tsx +++ b/packages/app/src/components/attachment-lightbox.test.tsx @@ -11,6 +11,7 @@ const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => { spacing: { 1: 4, 2: 8, 3: 12, 4: 16 }, iconSize: { sm: 14, md: 18, lg: 22 }, borderWidth: { 1: 1 }, + opacity: { 50: 0.5 }, borderRadius: { full: 999, md: 6, lg: 8 }, fontSize: { xs: 11, sm: 13, base: 15 }, fontWeight: { normal: "400" }, diff --git a/packages/app/src/components/headers/compact-header.tsx b/packages/app/src/components/headers/compact-header.tsx index cbc161ae66..1c3b45e0b3 100644 --- a/packages/app/src/components/headers/compact-header.tsx +++ b/packages/app/src/components/headers/compact-header.tsx @@ -12,8 +12,13 @@ import { iconButtonChromeGlyphSize, mutedIconColorMapping, } from "@/components/ui/icon-button-chrome"; -import { HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout"; +import { + HEADER_INNER_HEIGHT, + HEADER_INNER_HEIGHT_MOBILE, + HEADER_TOP_PADDING_MOBILE, +} from "@/constants/layout"; import type { ShortcutKey } from "@/utils/format-shortcut"; +import { useTopBand } from "@/window-layout"; const ThemedArrowLeft = withUnistyles(ArrowLeft); @@ -39,7 +44,8 @@ interface CompactHeaderProps { * left-aligned between them. Neither the header nor its islands draw a background, so it reads the * same in flow and floating over content (see `FloatingHeaderLayout`, whose fade gives the icons * contrast). Its height matches the old flat header, so anything - * offset by the header height still lines up. + * offset by the header height still lines up. In a window's top band it takes the top bar's + * place, like the full header. The column hosting it reserves any window-chrome corner it owns. */ export function CompactHeader({ navigation, @@ -51,22 +57,23 @@ export function CompactHeader({ actions, }: CompactHeaderProps) { const insets = useSafeAreaInsets(); + const inTopBand = useTopBand() !== null; const rootStyle = useMemo( () => [ styles.root, { - paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE, + paddingTop: insets.top + (inTopBand ? 0 : HEADER_TOP_PADDING_MOBILE), marginLeft: insets.left, marginRight: insets.right, }, ], - [insets.top, insets.left, insets.right], + [inTopBand, insets.top, insets.left, insets.right], ); return ( - + {navigation === "menu" ? : } @@ -148,6 +155,12 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", gap: theme.spacing[2], }, + topBandRow: { + height: HEADER_INNER_HEIGHT, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, titleGroup: { flex: 1, minWidth: 0, diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 2adc95e7f6..ba40ab41fd 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -31,7 +31,7 @@ import { resolveDesktopSidebarInsetLeft, resolveDesktopSidebarWidth, } from "@/components/desktop-sidebar-layout"; -import { useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout"; +import { useTopBand, useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout"; import { SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET, @@ -807,9 +807,11 @@ function DesktopSidebar({ ], [insetsTop, insets.bottom, insets.left, overlay, divisionLayout], ); + // Below the window controls' row, or in the top band's place, the first row starts flush. + const inTopBand = useTopBand() !== null; const sidebarHeaderGroupStyle = useMemo( - () => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome], - [ownsTopLeft], + () => [styles.sidebarHeaderGroup, (ownsTopLeft || inTopBand) && styles.sidebarHeaderGroupFlush], + [ownsTopLeft, inTopBand], ); return ( ({ borderBottomWidth: 1, borderBottomColor: theme.colors.border, }, - sidebarHeaderGroupBelowChrome: { + sidebarHeaderGroupFlush: { paddingTop: 0, }, // The nav block caps at half the sidebar; a long item list scrolls inside the diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index c1c375fbae..cb4d863448 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -164,7 +164,11 @@ import { WorkspaceExplorerSidebarToggle, WorkspaceHeaderExplorerToggle, } from "@/screens/workspace/workspace-explorer-toggle"; -import { useHasWindowChromeObstruction } from "@/utils/desktop-window"; +import { + useHasWindowChromeObstruction, + WindowChromeRegion, + WindowChromeSafeArea, +} from "@/utils/desktop-window"; import { resolveWorkspaceHeaderRenderState, type WorkspaceHeaderCheckoutState, @@ -1029,6 +1033,23 @@ function useLastMainPane(input: { return lastMainPaneRef; } +/** The docked explorer beside the center column takes the top-right window-chrome corner. */ +function DesktopCenterColumnChrome({ + explorerOpen, + focusModeEnabled, + children, +}: { + explorerOpen: boolean; + focusModeEnabled: boolean; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + function WorkspaceScreenContent({ serverId, workspaceId, @@ -3516,6 +3537,11 @@ function WorkspaceScreenContent({ }, }; const compactWorkspaceHeader = renderCompactWorkspaceHeader(workspaceCompactHeaderTabs); + // SplitContainer reserves the corner around its own compact headers; this is the column's. + const centerColumnCompactHeader = useMemo( + () => {compactWorkspaceHeader}, + [compactWorkspaceHeader], + ); const fullWorkspaceActions = renderCompactWorkspaceHeader(workspaceCompactHeaderTabs, true); const desktopSplitContent = useMemo(() => { if (!canRenderDesktopPaneSplits || !workspaceLayout || !persistenceKey) { @@ -3639,9 +3665,12 @@ function WorkspaceScreenContent({ ) : ( - + {rendersDesktopSplitContent ? null : ( - + {fullWorkspaceActions} {renderWorkspaceScreenHeader()} @@ -3678,7 +3707,7 @@ function WorkspaceScreenContent({ {desktopContent} - + ); const [workspaceViewportWidth, setWorkspaceViewportWidth] = useState(0); diff --git a/packages/app/src/utils/desktop-window.test.ts b/packages/app/src/utils/desktop-window.test.ts index 21f35537dc..fcc5bcea91 100644 --- a/packages/app/src/utils/desktop-window.test.ts +++ b/packages/app/src/utils/desktop-window.test.ts @@ -9,30 +9,61 @@ import { describe("window chrome", () => { it("has no corner obstruction outside Electron or in fullscreen", () => { - expect(resolveWindowChromeObstruction({ mode: null, isFullscreen: false })).toEqual({ + expect( + resolveWindowChromeObstruction({ mode: null, isFullscreen: false, topBand: null }), + ).toEqual({ topLeft: null, topRight: null, }); - expect(resolveWindowChromeObstruction({ mode: "native-mac", isFullscreen: true })).toEqual({ + expect( + resolveWindowChromeObstruction({ mode: "native-mac", isFullscreen: true, topBand: null }), + ).toEqual({ topLeft: null, topRight: null, }); }); it("places native controls in their physical top corner", () => { - expect(resolveWindowChromeObstruction({ mode: "native-mac", isFullscreen: false })).toEqual({ + expect( + resolveWindowChromeObstruction({ mode: "native-mac", isFullscreen: false, topBand: null }), + ).toEqual({ topLeft: { width: 78, height: 45 }, topRight: null, }); - expect(resolveWindowChromeObstruction({ mode: "custom-windows", isFullscreen: false })).toEqual( - { topLeft: null, topRight: { width: 138, height: 36 } }, - ); - expect(resolveWindowChromeObstruction({ mode: "custom-linux", isFullscreen: false })).toEqual({ + expect( + resolveWindowChromeObstruction({ + mode: "custom-windows", + isFullscreen: false, + topBand: null, + }), + ).toEqual({ topLeft: null, topRight: { width: 138, height: 36 } }); + expect( + resolveWindowChromeObstruction({ mode: "custom-linux", isFullscreen: false, topBand: null }), + ).toEqual({ topLeft: null, topRight: { width: 108, height: 36 }, }); }); + // Measured: iPhone Duo inner screen, portrait. The top bar ends at x 525, where the status + // region (0..82) starts; the band starts at y 26. + it("treats the status region beside a native top band as top-right window controls", () => { + expect( + resolveWindowChromeObstruction({ + mode: null, + isFullscreen: false, + topBand: { top: 26, trailingObstruction: { width: 144, height: 56 } }, + }), + ).toEqual({ topLeft: null, topRight: { width: 144, height: 56 } }); + expect( + resolveWindowChromeObstruction({ + mode: null, + isFullscreen: false, + topBand: { top: 26, trailingObstruction: null }, + }), + ).toEqual({ topLeft: null, topRight: null }); + }); + it("insets and reserves only claimed corners", () => { const obstruction = { topLeft: { width: 80, height: 28 }, topRight: { width: 48, height: 32 } }; expect( diff --git a/packages/app/src/utils/desktop-window.tsx b/packages/app/src/utils/desktop-window.tsx index fa1f397e1c..5947764709 100644 --- a/packages/app/src/utils/desktop-window.tsx +++ b/packages/app/src/utils/desktop-window.tsx @@ -12,6 +12,7 @@ import { getDesktopWindowControlsWidth, } from "@/desktop/window-chrome-presentation"; import { isNative } from "@/constants/platform"; +import { useTopBand, type TopBand } from "@/window-layout"; export type WindowChromeCorners = "none" | "top-left" | "top-right" | "both"; type WindowChromeSafeAreaPlacement = "inline" | "below"; @@ -105,10 +106,15 @@ export function removeWindowChromeCorner( return corner === "top-left" ? "top-right" : "top-left"; } +/** Desktop window controls, or the system region a native top band gives way to. */ export function resolveWindowChromeObstruction(input: { mode: DesktopWindowChromeMode | null; isFullscreen: boolean; + topBand: TopBand | null; }): WindowChromeObstruction { + if (input.topBand?.trailingObstruction) { + return { topLeft: null, topRight: input.topBand.trailingObstruction }; + } if (!input.mode || input.isFullscreen) return EMPTY_OBSTRUCTION; if (input.mode === "native-mac") { return { @@ -217,13 +223,15 @@ export function WindowChromeProvider({ children }: { children: ReactNode }) { }; }, []); + const topBand = useTopBand(); const obstruction = useMemo( () => resolveWindowChromeObstruction({ mode: isElectronReady ? getDesktopWindowChromeMode() : null, isFullscreen: windowState.isFullscreen, + topBand, }), - [isElectronReady, windowState.isFullscreen], + [isElectronReady, windowState.isFullscreen, topBand], ); const desktopWindowChrome = useMemo( () => ({ diff --git a/packages/app/src/window-layout/context.ts b/packages/app/src/window-layout/context.ts index e2d0246a92..b1da8c58f8 100644 --- a/packages/app/src/window-layout/context.ts +++ b/packages/app/src/window-layout/context.ts @@ -1,9 +1,12 @@ import { createContext, useContext } from "react"; import { useWindowDimensions } from "react-native"; -import type { WindowLayout } from "./model"; +import type { TopBand, WindowLayout } from "./model"; export const WindowLayoutContext = createContext(null); +/** Published by `WindowLayoutProvider` beside the safe-area insets it derives from it. */ +export const TopBandContext = createContext(null); + export function useWindowLayout(): WindowLayout | null { const layout = useContext(WindowLayoutContext); const { width, height } = useWindowDimensions(); @@ -13,3 +16,8 @@ export function useWindowLayout(): WindowLayout | null { ? layout : null; } + +/** The top band beside the system's status region, or null where the window has none. */ +export function useTopBand(): TopBand | null { + return useContext(TopBandContext); +} diff --git a/packages/app/src/window-layout/index.ios.tsx b/packages/app/src/window-layout/index.ios.tsx index 507133528e..47c985dace 100644 --- a/packages/app/src/window-layout/index.ios.tsx +++ b/packages/app/src/window-layout/index.ios.tsx @@ -2,18 +2,19 @@ import { useCallback, useMemo, useState, type ReactNode } from "react"; import { StyleSheet, View, type NativeSyntheticEvent, type ViewProps } from "react-native"; import { requireNativeViewManager } from "expo-modules-core"; import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; -import { WindowLayoutContext, useWindowLayout } from "./context"; -import { resolveSafeAreaInsets, type WindowLayout } from "./model"; +import { TopBandContext, WindowLayoutContext, useWindowLayout } from "./context"; +import { resolveSafeAreaInsets, resolveTopBand, type WindowLayout } from "./model"; export { useWindowLayout } from "./context"; export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions"; export { WindowLayoutSurface, WindowLayoutDivisionGap } from "./surface"; +export { useTopBand } from "./context"; export { resolveDesktopDivisionLayout, resolveWorkspaceActionPlacement, resolveWorkspaceBarActions, } from "./model"; -export type { DesktopDivisionLayout, WindowLayout, WindowLayoutRect } from "./model"; +export type { DesktopDivisionLayout, TopBand, WindowLayout, WindowLayoutRect } from "./model"; const GeometryProbe = requireNativeViewManager< ViewProps & { @@ -41,12 +42,23 @@ export function WindowLayoutProvider({ children }: { children: ReactNode }) { ); } -/** The one bottom inset every descendant reads through `useSafeAreaInsets()`. */ +/** + * The one top and bottom inset every descendant reads through `useSafeAreaInsets()`, and + * the top band that `WindowChromeProvider` reads through `useTopBand()`. + */ function WindowSafeAreaInsets({ children }: { children: ReactNode }) { const systemInsets = useSafeAreaInsets(); const layout = useWindowLayout(); const insets = useMemo(() => resolveSafeAreaInsets(systemInsets, layout), [systemInsets, layout]); - return {children}; + const topBand = useMemo( + () => resolveTopBand(systemInsets.top, layout), + [systemInsets.top, layout], + ); + return ( + + {children} + + ); } const styles = StyleSheet.create({ root: { flex: 1 } }); diff --git a/packages/app/src/window-layout/index.tsx b/packages/app/src/window-layout/index.tsx index c884d3e987..13a7c4c128 100644 --- a/packages/app/src/window-layout/index.tsx +++ b/packages/app/src/window-layout/index.tsx @@ -3,12 +3,13 @@ import type { ReactNode } from "react"; export { useWindowLayout } from "./context"; export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions"; export { WindowLayoutSurface, WindowLayoutDivisionGap } from "./surface"; +export { useTopBand } from "./context"; export { resolveDesktopDivisionLayout, resolveWorkspaceActionPlacement, resolveWorkspaceBarActions, } from "./model"; -export type { DesktopDivisionLayout, WindowLayout, WindowLayoutRect } from "./model"; +export type { DesktopDivisionLayout, TopBand, WindowLayout, WindowLayoutRect } from "./model"; export function WindowLayoutProvider({ children }: { children: ReactNode }) { return children; diff --git a/packages/app/src/window-layout/model.test.ts b/packages/app/src/window-layout/model.test.ts index e363d4313a..ae54a26cc9 100644 --- a/packages/app/src/window-layout/model.test.ts +++ b/packages/app/src/window-layout/model.test.ts @@ -1,35 +1,155 @@ import { describe, expect, it } from "vitest"; -import { resolveSafeAreaInsets, type WindowLayout } from "./model"; +import { + resolveSafeAreaInsets, + resolveTopBand, + type WindowLayout, + type WindowLayoutRect, +} from "./model"; const systemInsets = { top: 59, right: 0, bottom: 34, left: 0 }; -function layout(bottomClearance: number | null | undefined): WindowLayout { +function layout(input: Partial = {}): WindowLayout { return { bounds: { x: 0, y: 0, width: 402, height: 874 }, verticalBarEdge: "none", verticalBar: null, - bottomClearance, divisions: [], + ...input, }; } describe("resolveSafeAreaInsets", () => { it("replaces only the bottom with the measured bar clearance", () => { - expect(resolveSafeAreaInsets(systemInsets, layout(24))).toEqual({ + expect(resolveSafeAreaInsets(systemInsets, layout({ bottomClearance: 24 }))).toEqual({ ...systemInsets, bottom: 24, }); }); it("keeps a measured zero instead of falling back to the system inset", () => { - expect(resolveSafeAreaInsets(systemInsets, layout(0)).bottom).toBe(0); + expect(resolveSafeAreaInsets(systemInsets, layout({ bottomClearance: 0 })).bottom).toBe(0); }); it.each([null, undefined])("leaves system insets alone without a clearance (%s)", (value) => { - expect(resolveSafeAreaInsets(systemInsets, layout(value))).toBe(systemInsets); + expect(resolveSafeAreaInsets(systemInsets, layout({ bottomClearance: value }))).toBe( + systemInsets, + ); }); it("leaves system insets alone before the native layout arrives", () => { expect(resolveSafeAreaInsets(systemInsets, null)).toBe(systemInsets); }); + + it("starts the top where the top bar rests when it sits above the system top", () => { + const insets = resolveSafeAreaInsets( + systemInsets, + layout({ topBar: { x: 0, y: 12, width: 402, height: 44 } }), + ); + expect(insets).toEqual({ ...systemInsets, top: 12 }); + }); + + it("keeps the system top when the top bar rests at or below it", () => { + const insets = resolveSafeAreaInsets( + systemInsets, + layout({ topBar: { x: 0, y: 59, width: 402, height: 44 } }), + ); + expect(insets).toBe(systemInsets); + }); +}); + +// Measured: iPhone Duo inner screen, portrait (669×951), iOS 27.1 simulator unfolded in +// DeviceHub. +const duo = { + systemTop: 82, + topBar: { x: 16, y: 26, width: 509, height: 44 }, + statusRegion: { x: 525, y: 0, width: 144, height: 82 }, +}; + +function duoLayout(occlusions: WindowLayoutRect[]): WindowLayout { + return layout({ + bounds: { x: 0, y: 0, width: 669, height: 951 }, + topBar: duo.topBar, + occlusions, + }); +} + +describe("resolveTopBand", () => { + it("publishes the top bar's start and the status region past its trailing end", () => { + expect(resolveTopBand(duo.systemTop, duoLayout([duo.statusRegion]))).toEqual({ + top: 26, + trailingObstruction: { width: 144, height: 56 }, + }); + }); + + it("reaches as low as the deepest occlusion beside the bar", () => { + const deeper = { x: 600, y: 0, width: 69, height: 96 }; + expect( + resolveTopBand(duo.systemTop, duoLayout([duo.statusRegion, deeper]))?.trailingObstruction, + ).toEqual({ width: 144, height: 70 }); + }); + + it("rounds a fractional obstruction up so controls never touch the status region", () => { + const fractional = layout({ + bounds: { x: 0, y: 0, width: 669, height: 951 }, + topBar: { x: 16, y: 26, width: 509.25, height: 44 }, + occlusions: [{ x: 525.25, y: 0, width: 143.75, height: 82.5 }], + }); + expect(resolveTopBand(duo.systemTop, fractional)?.trailingObstruction).toEqual({ + width: 144, + height: 57, + }); + }); + + it("has no trailing obstruction when the bar reaches the window's trailing edge", () => { + const fullWidth = layout({ + bounds: { x: 0, y: 0, width: 669, height: 951 }, + topBar: { x: 0, y: 26, width: 669, height: 44 }, + }); + expect(resolveTopBand(duo.systemTop, fullWidth)).toEqual({ + top: 26, + trailingObstruction: null, + }); + }); + + it("ignores occlusions without a top bar guide (before iOS 27.1 or unresolved)", () => { + const withoutGuide = layout({ topBar: null, occlusions: [duo.statusRegion] }); + expect(resolveTopBand(duo.systemTop, withoutGuide)).toBeNull(); + expect(resolveTopBand(duo.systemTop, layout({ occlusions: [duo.statusRegion] }))).toBeNull(); + }); + + it("ignores occlusions when the top bar guide sits below the status region", () => { + const below = layout({ + topBar: { x: 16, y: 82, width: 637, height: 44 }, + occlusions: [duo.statusRegion], + }); + expect(resolveTopBand(duo.systemTop, below)).toBeNull(); + }); + + // Measured: iPhone 17 Pro, iOS 27.2 simulator, portrait. + it("is null on a regular iPhone, whose top bar starts at the status bar's bottom", () => { + const iphone = layout({ + topBar: { x: 18, y: 62, width: 366, height: 44 }, + occlusions: [{ x: 0, y: 0, width: 402, height: 54 }], + }); + expect(resolveTopBand(62, iphone)).toBeNull(); + }); + + // Measured: iPhone Duo outer screen, iOS 27.1 simulator. The status region is a trailing + // column, which the vertical bar layout already owns. + it("is null on the Duo outer screen, whose top bar starts below a zero system top", () => { + const outer = layout({ + bounds: { x: 0, y: 0, width: 466, height: 678 }, + verticalBarEdge: "trailing", + topBar: { x: 7 / 3, y: 26, width: 1139 / 3, height: 44 }, + occlusions: [ + { x: 1199 / 3, y: 88 / 3, width: 37, height: 37 }, + { x: 382, y: 0, width: 84, height: 174 }, + ], + }); + expect(resolveTopBand(0, outer)).toBeNull(); + }); + + it("is null before the native layout arrives", () => { + expect(resolveTopBand(duo.systemTop, null)).toBeNull(); + }); }); diff --git a/packages/app/src/window-layout/model.ts b/packages/app/src/window-layout/model.ts index d2ba0de709..0226d81bd5 100644 --- a/packages/app/src/window-layout/model.ts +++ b/packages/app/src/window-layout/model.ts @@ -14,18 +14,56 @@ export interface WindowLayout { * the system has no bottom bar guide. Zero is a measured value. */ bottomClearance?: number | null; + /** Where a top bar rests, or null until the system resolves its top bar guide. */ + topBar?: WindowLayoutRect | null; + /** Active system occlusions (status region, camera, Live Activities), margins included. */ + occlusions?: WindowLayoutRect[]; divisions: WindowLayoutRect[]; } -/** The window's measured bottom clearance replaces the rectangular safe-area bottom. */ -export function resolveSafeAreaInsets( +/** + * The band above the rectangular safe area that a top bar may use because the system + * occupies only its trailing end, like window controls on a desktop title bar. + */ +export interface TopBand { + /** Where the top bar starts, in window points. */ + top: number; + /** + * The system region past the bar's trailing end, measured from the band's top, or null + * when the bar reaches the window's trailing edge. + */ + trailingObstruction: { width: number; height: number } | null; +} + +/** Null unless the top bar guide starts above the system's rectangular safe area. */ +export function resolveTopBand(systemTop: number, layout: WindowLayout | null): TopBand | null { + const bar = layout?.topBar; + if (!layout || !bar || bar.y >= systemTop) return null; + const top = Math.max(0, bar.y); + const barEnd = bar.x + bar.width; + const width = Math.ceil(layout.bounds.width - barEnd); + if (width <= 0) return { top, trailingObstruction: null }; + const beside = (layout.occlusions ?? []).filter( + (frame) => frame.x + frame.width > barEnd && frame.y < bar.y + bar.height, + ); + const bottom = Math.max(bar.y + bar.height, ...beside.map((frame) => frame.y + frame.height)); + return { top, trailingObstruction: { width, height: Math.ceil(bottom - top) } }; +} + +/** + * The window's measured bars replace the rectangular safe area: the bottom bar's clearance, + * and the top bar's start when it sits above the system top. + */ +export function resolveSafeAreaInsets( systemInsets: Insets, layout: WindowLayout | null, ): Insets { const clearance = layout?.bottomClearance; - return typeof clearance === "number" && clearance !== systemInsets.bottom - ? { ...systemInsets, bottom: clearance } - : systemInsets; + const bottom = typeof clearance === "number" ? clearance : systemInsets.bottom; + const top = resolveTopBand(systemInsets.top, layout)?.top ?? systemInsets.top; + return top === systemInsets.top && bottom === systemInsets.bottom + ? systemInsets + : { ...systemInsets, top, bottom }; } export interface DesktopDivisionLayout {