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/headers/compact-header.tsx b/packages/app/src/components/headers/compact-header.tsx
index cbc161ae66..69f73cc438 100644
--- a/packages/app/src/components/headers/compact-header.tsx
+++ b/packages/app/src/components/headers/compact-header.tsx
@@ -1,6 +1,6 @@
-import { useCallback, useMemo, type ReactNode } from "react";
+import { useCallback, useMemo, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
-import { Text, View } from "react-native";
+import { Text, View, type LayoutChangeEvent } from "react-native";
import { router } from "expo-router";
import { ArrowLeft } from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -12,12 +12,19 @@ import {
iconButtonChromeGlyphSize,
mutedIconColorMapping,
} from "@/components/ui/icon-button-chrome";
+import { TOUCH_TARGET } from "@/components/ui/control-sizing";
import { HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
+import { SPACING } from "@/styles/theme";
+import { useTopBandRow } from "@/window-layout";
import type { ShortcutKey } from "@/utils/format-shortcut";
const ThemedArrowLeft = withUnistyles(ArrowLeft);
const NO_SHORTCUT: ShortcutKey[] = [];
+const ROW_PADDING = SPACING[3];
+const ROW_GAP = SPACING[2];
+// Below this the title is unreadable; the row starts below the system's occlusions instead.
+const MIN_TITLE_WIDTH = 96;
interface CompactHeaderProps {
/** The leading island: the sidebar menu, or a back button. */
@@ -51,21 +58,38 @@ export function CompactHeader({
actions,
}: CompactHeaderProps) {
const insets = useSafeAreaInsets();
+ const [actionsWidth, setActionsWidth] = useState(0);
+ const measureActions = useCallback(
+ (event: LayoutChangeEvent) => setActionsWidth(event.nativeEvent.layout.width),
+ [],
+ );
+ // The trailing side gives way to the occlusion, so it needs no padding of its own.
+ const topBand = useTopBandRow({
+ offset: insets.top + HEADER_TOP_PADDING_MOBILE,
+ height: HEADER_INNER_HEIGHT_MOBILE,
+ minimumWidth:
+ ROW_PADDING +
+ TOUCH_TARGET +
+ ROW_GAP +
+ MIN_TITLE_WIDTH +
+ (actions ? ROW_GAP + actionsWidth : 0),
+ });
const rootStyle = useMemo(
() => [
styles.root,
{
- paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE,
+ paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE + topBand.top,
+ paddingRight: Math.max(ROW_PADDING, topBand.trailing),
marginLeft: insets.left,
marginRight: insets.right,
},
],
- [insets.top, insets.left, insets.right],
+ [insets.top, insets.left, insets.right, topBand.top, topBand.trailing],
);
return (
-
+
{navigation === "menu" ? : }
@@ -78,7 +102,11 @@ export function CompactHeader({
subtitle={subtitle}
/>
- {actions ? {actions} : null}
+ {actions ? (
+
+ {actions}
+
+ ) : null}
@@ -140,13 +168,13 @@ function BackButton({ onBack }: { onBack?: () => void }) {
const styles = StyleSheet.create((theme) => ({
root: {
- paddingHorizontal: theme.spacing[3],
+ paddingLeft: ROW_PADDING,
},
row: {
height: HEADER_INNER_HEIGHT_MOBILE,
flexDirection: "row",
alignItems: "center",
- gap: theme.spacing[2],
+ gap: ROW_GAP,
},
titleGroup: {
flex: 1,
diff --git a/packages/app/src/components/headers/screen-header.tsx b/packages/app/src/components/headers/screen-header.tsx
index 6492658389..2af603ade4 100644
--- a/packages/app/src/components/headers/screen-header.tsx
+++ b/packages/app/src/components/headers/screen-header.tsx
@@ -1,5 +1,5 @@
import { ComposerDockBackground } from "@/composer/dock";
-import { useMemo, type ReactNode } from "react";
+import { useCallback, useMemo, useState, type ReactNode } from "react";
import type { LayoutChangeEvent } from "react-native";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -12,6 +12,11 @@ import {
} from "@/constants/layout";
import { WindowChromeSafeArea } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
+import { TOUCH_TARGET } from "@/components/ui/control-sizing";
+import { useTopBandRow } from "@/window-layout";
+
+// Leading content that must stay readable in the top band: a navigation button and a title.
+const MIN_LEADING_WIDTH = TOUCH_TARGET + 8 + 96;
interface ScreenHeaderProps {
left?: ReactNode;
@@ -41,17 +46,37 @@ export function ScreenHeader({
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const baseHorizontalPadding = isMobile ? theme.spacing[2] : theme.spacing[3];
+ const [rightWidth, setRightWidth] = useState(0);
+ const measureRight = useCallback(
+ (event: LayoutChangeEvent) => setRightWidth(event.nativeEvent.layout.width),
+ [],
+ );
+ const topBand = useTopBandRow({
+ offset: insets.top + topPadding,
+ height: isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT,
+ minimumWidth: baseHorizontalPadding + MIN_LEADING_WIDTH + rightWidth,
+ });
const innerStyle = useMemo(
- () => [styles.inner, { paddingTop: insets.top + topPadding }],
- [insets.top, topPadding],
+ () => [styles.inner, { paddingTop: insets.top + topPadding + topBand.top }],
+ [insets.top, topPadding, topBand.top],
);
const rowStyle = useMemo(() => [styles.row, borderless && styles.borderless], [borderless]);
const leftCombinedStyle = useMemo(() => [styles.left, leftStyle], [leftStyle]);
- const rightCombinedStyle = useMemo(() => [styles.right, rightStyle], [rightStyle]);
+ // The occlusion replaces the row's trailing padding rather than adding to it.
+ const rightCombinedStyle = useMemo(
+ () => [
+ styles.right,
+ rightStyle,
+ topBand.trailing > baseHorizontalPadding && {
+ marginRight: topBand.trailing - baseHorizontalPadding,
+ },
+ ],
+ [rightStyle, topBand.trailing, baseHorizontalPadding],
+ );
return (
-
+
{left}
- {right}
+
+ {right}
+
diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx
index b4b54db3db..5debd60cb9 100644
--- a/packages/app/src/components/left-sidebar.tsx
+++ b/packages/app/src/components/left-sidebar.tsx
@@ -29,7 +29,7 @@ import {
resolveDesktopSidebarInsetLeft,
resolveDesktopSidebarWidth,
} from "@/components/desktop-sidebar-layout";
-import { useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout";
+import { useWindowLayout, resolveDesktopDivisionLayout, useTopBandRow } from "@/window-layout";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
SIDEBAR_RESIZE_FAIL_OFFSET,
@@ -46,6 +46,7 @@ import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-heade
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
+import { TOUCH_TARGET } from "@/components/ui/control-sizing";
import { isNative as platformIsNative } from "@/constants/platform";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useImportSession } from "@/hooks/use-import-session";
@@ -527,6 +528,14 @@ function MobileSidebar({
closeSidebar();
}, [closeSidebar]);
+ // The first nav row carries the close button; the panel slides in, so re-measure once settled.
+ const navBand = useTopBandRow({
+ offset: theme.spacing[2],
+ height: TOUCH_TARGET,
+ minimumWidth: TOUCH_TARGET * 2,
+ settled: active,
+ });
+ const navBandStyle = useMemo(() => ({ paddingTop: navBand.top }), [navBand.top]);
const closeButton = useMemo(
() => (
),
- [labels.closeSidebar, closeSidebar, insets.right],
+ [labels.closeSidebar, closeSidebar, insets.right, navBand.trailing],
);
const mobileSidebarInsetStyle = useMemo(
@@ -550,11 +559,13 @@ function MobileSidebar({
);
const navRows = (
-
+
+
+
);
return (
@@ -894,8 +905,8 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minHeight: 0,
},
- mobileCloseButton: (rightInset: number) => ({
- marginRight: theme.spacing[2] + rightInset,
+ mobileCloseButton: (rightInset: number, topBandTrailing: number) => ({
+ marginRight: Math.max(theme.spacing[2] + rightInset, topBandTrailing),
}),
desktopSidebarBorder: (hasDivision: boolean) => ({
borderRightWidth: hasDivision ? 0 : 1,
diff --git a/packages/app/src/components/toast-host.tsx b/packages/app/src/components/toast-host.tsx
index 62a15bad04..d5d3eff1b8 100644
--- a/packages/app/src/components/toast-host.tsx
+++ b/packages/app/src/components/toast-host.tsx
@@ -19,6 +19,8 @@ import type { Theme } from "@/styles/theme";
import { useTranslation } from "react-i18next";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
+import { TOUCH_TARGET } from "@/components/ui/control-sizing";
+import { useTopBandRow } from "@/window-layout";
import { AlertTriangle, CheckCircle2, Info } from "lucide-react-native";
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
import {
@@ -200,13 +202,30 @@ export function ToastViewport({
const topOffset =
placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : panelClearance;
+ // A centred toast cannot trim one side, so it always moves below an occlusion it meets.
+ const topBand = useTopBandRow({
+ offset: 0,
+ height: TOUCH_TARGET,
+ minimumWidth: Number.POSITIVE_INFINITY,
+ });
+ const widthBoundaryStyle = useMemo(
+ () => [styles.widthBoundary, { paddingTop: topBand.top }],
+ [topBand.top],
+ );
+
const content = (
-
+
{toast ? (
[styles.enterButton, { right: -6 + railOffset }], [railOffset]);
const exitStyle = useMemo(() => [styles.iconButton, { marginRight: railOffset }], [railOffset]);
+ const exitSize = TOOLBAR_CONTROL_SIZES[controlSizing];
+ const topBand = useTopBandRow({ offset: 0, height: exitSize, minimumWidth: exitSize });
+ const exitRowStyle = useMemo(
+ () => [styles.exitRow, { paddingTop: topBand.top, paddingRight: topBand.trailing }],
+ [topBand.top, topBand.trailing],
+ );
return (
{fullscreen ? (
-
+