From 843ce6ebffbd2e85a8285051e86f454659226114 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 8 Nov 2026 02:46:56 +0100 Subject: [PATCH] fix(app): dodge the Duo status region like window controls The status region beside the iPhone Duo's top band is the window's top-right obstruction, the same one Windows and Linux window controls publish. Every row in the band (workspace header in both widths, left sidebar's first row, explorer tab rail) starts at the band's top, sits in the top bar's height, and the row owning the top-right corner pads past the status region. Nothing starts below it. The docked explorer takes the corner from the center column. Removes the per-row clearance (useTopBandRow) and its start-below fallback. Co-authored-by: Elsa Granger --- .../src/components/headers/compact-header.tsx | 56 ++++----- .../src/components/headers/screen-header.tsx | 25 +--- packages/app/src/components/left-sidebar.tsx | 39 +++--- packages/app/src/components/toast-host.tsx | 21 +--- .../app/src/composer/input/presentation.tsx | 9 +- .../screens/workspace/explorer-sidebar.tsx | 91 ++++++-------- .../screens/workspace/workspace-screen.tsx | 26 +++- packages/app/src/utils/desktop-window.test.ts | 45 +++++-- packages/app/src/utils/desktop-window.tsx | 10 +- packages/app/src/window-layout/context.ts | 5 + packages/app/src/window-layout/index.ios.tsx | 6 +- packages/app/src/window-layout/index.tsx | 4 +- packages/app/src/window-layout/model.test.ts | 113 +++++------------- packages/app/src/window-layout/model.ts | 58 +++------ packages/app/src/window-layout/top-band.tsx | 78 ------------ 15 files changed, 216 insertions(+), 370 deletions(-) delete mode 100644 packages/app/src/window-layout/top-band.tsx diff --git a/packages/app/src/components/headers/compact-header.tsx b/packages/app/src/components/headers/compact-header.tsx index c87c6df956..e150a4a85b 100644 --- a/packages/app/src/components/headers/compact-header.tsx +++ b/packages/app/src/components/headers/compact-header.tsx @@ -12,15 +12,21 @@ import { iconButtonChromeGlyphSize, mutedIconColorMapping, } from "@/components/ui/icon-button-chrome"; -import { HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout"; -import { SPACING } from "@/styles/theme"; -import { useTopBandRow } from "@/window-layout"; +import { + HEADER_INNER_HEIGHT, + HEADER_INNER_HEIGHT_MOBILE, + HEADER_TOP_PADDING_MOBILE, +} from "@/constants/layout"; +import { WindowChromeSafeArea } from "@/utils/desktop-window"; import type { ShortcutKey } from "@/utils/format-shortcut"; +import { useTopBand } from "@/window-layout"; const ThemedArrowLeft = withUnistyles(ArrowLeft); +const HeaderSafeArea = withUnistyles(WindowChromeSafeArea, (theme) => ({ + horizontalPadding: theme.spacing[3], +})); const NO_SHORTCUT: ShortcutKey[] = []; -const ROW_PADDING = SPACING[3]; interface CompactHeaderProps { /** The leading island: the sidebar menu, or a back button. */ @@ -42,7 +48,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, and keeps clear of the status region as it does of window controls. */ export function CompactHeader({ navigation, @@ -54,30 +61,20 @@ export function CompactHeader({ actions, }: CompactHeaderProps) { const insets = useSafeAreaInsets(); - // 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, - // The header stays in the band in every sidebar state; the title gives up width instead. - minimumWidth: 0, - }); + const inTopBand = useTopBand() !== null; const rootStyle = useMemo( - () => [ - styles.root, - { - 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, topBand.top, topBand.trailing], + () => ({ + paddingTop: insets.top + (inTopBand ? 0 : HEADER_TOP_PADDING_MOBILE), + marginLeft: insets.left, + marginRight: insets.right, + }), + [inTopBand, insets.top, insets.left, insets.right], ); return ( - - + + {navigation === "menu" ? : } @@ -91,7 +88,7 @@ export function CompactHeader({ {actions ? {actions} : null} - + ); } @@ -150,15 +147,18 @@ function BackButton({ onBack }: { onBack?: () => void }) { } const styles = StyleSheet.create((theme) => ({ - root: { - paddingLeft: ROW_PADDING, - }, row: { height: HEADER_INNER_HEIGHT_MOBILE, flexDirection: "row", 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/headers/screen-header.tsx b/packages/app/src/components/headers/screen-header.tsx index feabfe1ecb..6492658389 100644 --- a/packages/app/src/components/headers/screen-header.tsx +++ b/packages/app/src/components/headers/screen-header.tsx @@ -12,7 +12,6 @@ import { } from "@/constants/layout"; import { WindowChromeSafeArea } from "@/utils/desktop-window"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; -import { useTopBandRow } from "@/window-layout"; interface ScreenHeaderProps { left?: ReactNode; @@ -42,33 +41,17 @@ export function ScreenHeader({ const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; const baseHorizontalPadding = isMobile ? theme.spacing[2] : theme.spacing[3]; - const topBand = useTopBandRow({ - offset: insets.top + topPadding, - height: isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT, - // The header stays in the band in every sidebar state; the title gives up width instead. - minimumWidth: 0, - }); const innerStyle = useMemo( - () => [styles.inner, { paddingTop: insets.top + topPadding + topBand.top }], - [insets.top, topPadding, topBand.top], + () => [styles.inner, { paddingTop: insets.top + topPadding }], + [insets.top, topPadding], ); const rowStyle = useMemo(() => [styles.row, borderless && styles.borderless], [borderless]); const leftCombinedStyle = useMemo(() => [styles.left, leftStyle], [leftStyle]); - // 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], - ); + const rightCombinedStyle = useMemo(() => [styles.right, rightStyle], [rightStyle]); return ( - + ({ paddingTop: navBand.top }), [navBand.top]); const closeButton = useMemo( () => ( ), - [labels.closeSidebar, closeSidebar, insets.right, navBand.trailing], + [labels.closeSidebar, closeSidebar, insets.right], ); const mobileSidebarInsetStyle = useMemo( @@ -559,13 +550,11 @@ function MobileSidebar({ ); const navRows = ( - - - + ); return ( @@ -759,9 +748,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 @@ -905,8 +896,8 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, minHeight: 0, }, - mobileCloseButton: (rightInset: number, topBandTrailing: number) => ({ - marginRight: Math.max(theme.spacing[2] + rightInset, topBandTrailing), + mobileCloseButton: (rightInset: number) => ({ + marginRight: theme.spacing[2] + rightInset, }), 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 d5d3eff1b8..62a15bad04 100644 --- a/packages/app/src/components/toast-host.tsx +++ b/packages/app/src/components/toast-host.tsx @@ -19,8 +19,6 @@ 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 { @@ -202,30 +200,13 @@ 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 ? ( - +