fix(mobile): keep Android content clear of the navigation bar

Android 15+ enforces edge-to-edge, and Capacitor stops padding the WebView
once it can hand the insets to the page instead — so the app draws under
the gesture/navigation bar. Only the top inset was ever consumed: full
cover surfaces ignored the bottom one entirely, which is why a settings
list ran under the bar, and the chat shell reserved half of it, which is
enough for a home indicator but not for a bar that swallows taps.

Reserve the full inset in the shell on Android and give the surfaces that
own the bottom edge — fullscreen surfaces, the workspace drawer, bottom
sheets — an opt-in class that pads them. The sessions drawer already did
this through its footer, so it stays as it is. Capacitor reports a zero
bottom inset while the keyboard is up, so none of this stacks with the
keyboard inset. iOS deliberately keeps its softer visual inset.

Testing: package type-check and lint; drove the mobile surface in a
browser with the Android platform class and a simulated 48px inset (shell
and surfaces reserve exactly that, drawer footer unchanged, and removing
the class returns every surface to zero).
This commit is contained in:
Bohdan Triapitsyn
2026-09-09 17:41:14 +03:00
parent fd416a3678
commit 1294a90e52
4 changed files with 20 additions and 4 deletions
@@ -175,7 +175,7 @@ export const MobileFullscreenSurface: React.FC<MobileFullscreenSurfaceProps> = (
'flex flex-col bg-background text-foreground', 'flex flex-col bg-background text-foreground',
isDialog isDialog
? 'h-[min(88dvh,860px)] w-full max-w-[720px] overflow-hidden rounded-2xl border border-border/70 shadow-[0_24px_64px_rgb(0_0_0_/_0.32)]' ? 'h-[min(88dvh,860px)] w-full max-w-[720px] overflow-hidden rounded-2xl border border-border/70 shadow-[0_24px_64px_rgb(0_0_0_/_0.32)]'
: 'oc-keyboard-inset-surface fixed inset-0 z-50', : 'oc-keyboard-inset-surface oc-bottom-safe-surface fixed inset-0 z-50',
)} )}
style={isDialog ? { style={isDialog ? {
// Scale/fade instead of the push slide: the card is not a navigation // Scale/fade instead of the push slide: the card is not a navigation
@@ -249,7 +249,7 @@ export const MobileFullscreenSurface: React.FC<MobileFullscreenSurfaceProps> = (
return createPortal( return createPortal(
<div <div
className="oc-keyboard-inset-surface fixed inset-0 z-50 flex items-center justify-center p-4 transition-opacity duration-200 ease-out" className="oc-keyboard-inset-surface oc-bottom-safe-surface fixed inset-0 z-50 flex items-center justify-center p-4 transition-opacity duration-200 ease-out"
style={{ style={{
background: 'rgb(0 0 0 / 0.45)', background: 'rgb(0 0 0 / 0.45)',
opacity: entered ? 1 : 0, opacity: entered ? 1 : 0,
@@ -295,7 +295,7 @@ export const MobileWorkspaceDrawer: React.FC<{
aria-modal="true" aria-modal="true"
aria-label={t('mobile.header.openWorkspaceAria')} aria-label={t('mobile.header.openWorkspaceAria')}
aria-hidden={!open} aria-hidden={!open}
className="oc-keyboard-inset-surface fixed inset-0 z-50 flex flex-col bg-background text-foreground" className="oc-keyboard-inset-surface oc-bottom-safe-surface fixed inset-0 z-50 flex flex-col bg-background text-foreground"
style={{ style={{
paddingTop: 'var(--oc-safe-area-top, 0px)', paddingTop: 'var(--oc-safe-area-top, 0px)',
// Settled state drops the transform entirely so the drawer isn't kept // Settled state drops the transform entirely so the drawer isn't kept
@@ -112,7 +112,7 @@ export const MobileOverlayPanel: React.FC<MobileOverlayPanelProps> = ({
const content = ( const content = (
<div <div
className={cn( className={cn(
'oc-keyboard-inset-surface fixed inset-0 z-[60] flex flex-col bg-[rgb(0_0_0_/_0.45)] transition-opacity duration-200 ease-out', 'oc-keyboard-inset-surface oc-bottom-safe-surface fixed inset-0 z-[60] flex flex-col bg-[rgb(0_0_0_/_0.45)] transition-opacity duration-200 ease-out',
!enterSettled && 'oc-keyboard-inset-snap', !enterSettled && 'oc-keyboard-inset-snap',
entered ? 'opacity-100' : 'opacity-0', entered ? 'opacity-100' : 'opacity-0',
)} )}
+16
View File
@@ -470,6 +470,22 @@
padding-top: var(--oc-safe-area-top); padding-top: var(--oc-safe-area-top);
} }
/* Android 15+ enforces edge-to-edge, and once the WebView can read the insets
itself Capacitor stops padding the view and just hands them over — so the app
draws under the navigation/gesture bar and every full-cover surface has to
keep its own content clear of it. Marked surfaces opt in through
.oc-bottom-safe-surface; iOS deliberately stays on its softer visual inset,
where the home indicator is a thin overlay rather than a bar that eats taps.
Capacitor reports a zero bottom inset while the keyboard is up, so this never
stacks with --oc-keyboard-inset. */
:root.oc-capacitor-app.oc-platform-android {
--oc-app-bottom-safe: max(16px, var(--oc-safe-area-bottom));
}
:root.oc-capacitor-app.oc-platform-android .oc-bottom-safe-surface {
padding-bottom: var(--oc-safe-area-bottom);
}
:root.oc-capacitor-app.oc-platform-android [data-sonner-toaster][data-y-position='top'] { :root.oc-capacitor-app.oc-platform-android [data-sonner-toaster][data-y-position='top'] {
top: calc(var(--oc-safe-area-top) + 16px) !important; top: calc(var(--oc-safe-area-top) + 16px) !important;
} }