fix: Bug - Android app view is below titlebar and action bar on android

Respect Android system bar insets with safe-area shell padding, inset-aware modal and bottom-sheet layouts, transparent edge-to-edge themes, and a Capacitor SystemBars refresh on mobile startup.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-11 21:24:48 +02:00
parent a01abbb1bf
commit 6b9a39fe4a
10 changed files with 128 additions and 13 deletions

View File

@@ -1,6 +1,7 @@
<div
appThemeNode="appRoot"
class="workspace-bright-theme relative h-full overflow-hidden bg-background text-foreground"
[class.metoyou-safe-area-shell]="isMobile()"
>
<div
class="h-full min-h-0 min-w-0 overflow-hidden"

View File

@@ -2,7 +2,7 @@
@if (isOpen() && !isThemeStudioFullscreen()) {
<!-- Backdrop (hidden on mobile where the modal is full-screen) -->
<div
class="fixed inset-0 z-[90] hidden bg-black/60 backdrop-blur-sm transition-opacity duration-200 md:block"
class="fixed metoyou-fixed-safe-viewport z-[90] hidden bg-black/60 backdrop-blur-sm transition-opacity duration-200 md:block"
[class.opacity-100]="animating()"
[class.opacity-0]="!animating()"
(click)="onBackdropClick()"
@@ -14,7 +14,7 @@
></div>
<!-- Modal: full-screen page on mobile, centered dialog on desktop -->
<div class="fixed inset-0 z-[91] flex pointer-events-none md:items-center md:justify-center md:p-4">
<div class="fixed metoyou-fixed-safe-viewport z-[91] flex pointer-events-none md:items-center md:justify-center md:p-4">
<div
appThemeNode="settingsModalSurface"
class="pointer-events-auto relative flex h-full w-full overflow-hidden bg-card transition-all duration-200 md:h-[min(720px,88vh)] md:max-w-5xl md:rounded-lg md:border md:border-border md:shadow-lg"

View File

@@ -1,17 +1,53 @@
import {
beforeEach,
describe,
expect,
it
it,
vi
} from 'vitest';
import { applyMobileSafeAreaDefaults, getSafeAreaInsetCSSValue } from './mobile-safe-area.rules';
import {
applyMobileSafeAreaDefaults,
getSafeAreaInsetCSSValue,
MOBILE_FIXED_SAFE_BOTTOM_SHEET_CLASS,
MOBILE_FIXED_SAFE_VIEWPORT_CLASS,
MOBILE_SAFE_AREA_SHELL_CLASS,
syncMobileSafeAreaInsets
} from './mobile-safe-area.rules';
const systemBars = vi.hoisted(() => ({
setStyle: vi.fn(() => Promise.resolve())
}));
vi.mock('@capacitor/core', () => ({
SystemBars: systemBars,
SystemBarsStyle: { Dark: 'DARK' }
}));
vi.mock('./platform-detection.rules', () => ({
isCapacitorNativeRuntime: vi.fn(() => false)
}));
import { isCapacitorNativeRuntime } from './platform-detection.rules';
describe('mobile-safe-area.rules', () => {
beforeEach(() => {
vi.mocked(isCapacitorNativeRuntime).mockReturnValue(false);
systemBars.setStyle.mockClear();
vi.unstubAllGlobals();
});
it('builds CSS values with Capacitor and env fallbacks', () => {
expect(getSafeAreaInsetCSSValue('top')).toBe('var(--safe-area-inset-top, env(safe-area-inset-top, 0px))');
expect(getSafeAreaInsetCSSValue('bottom')).toBe('var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))');
});
it('exports the global safe-area layout classes used by the mobile shell and overlays', () => {
expect(MOBILE_FIXED_SAFE_VIEWPORT_CLASS).toBe('metoyou-fixed-safe-viewport');
expect(MOBILE_FIXED_SAFE_BOTTOM_SHEET_CLASS).toBe('metoyou-fixed-safe-bottom-sheet');
expect(MOBILE_SAFE_AREA_SHELL_CLASS).toBe('metoyou-safe-area-shell');
});
it('sets default safe-area variables on the document root', () => {
const properties = new Map<string, string>();
const root = {
@@ -34,4 +70,18 @@ describe('mobile-safe-area.rules', () => {
it('ignores null roots instead of throwing', () => {
expect(() => applyMobileSafeAreaDefaults(null)).not.toThrow();
});
it('asks Capacitor SystemBars to refresh CSS insets on native shells', async () => {
const onDOMReady = vi.fn();
vi.mocked(isCapacitorNativeRuntime).mockReturnValue(true);
vi.stubGlobal('window', {
CapacitorSystemBarsAndroidInterface: { onDOMReady }
});
await syncMobileSafeAreaInsets();
expect(onDOMReady).toHaveBeenCalledTimes(1);
expect(systemBars.setStyle).toHaveBeenCalledWith({ style: 'DARK' });
});
});

View File

@@ -1,3 +1,5 @@
import { isCapacitorNativeRuntime } from './platform-detection.rules';
const SAFE_AREA_SIDES = [
'top',
'right',
@@ -7,6 +9,15 @@ const SAFE_AREA_SIDES = [
export type SafeAreaSide = (typeof SAFE_AREA_SIDES)[number];
/** Global class that insets fixed full-viewport overlays below the status bar and above the nav bar. */
export const MOBILE_FIXED_SAFE_VIEWPORT_CLASS = 'metoyou-fixed-safe-viewport';
/** Global class for bottom sheets that anchor above the Android navigation bar. */
export const MOBILE_FIXED_SAFE_BOTTOM_SHEET_CLASS = 'metoyou-fixed-safe-bottom-sheet';
/** Global class applied to the mobile app shell so routed content respects system insets. */
export const MOBILE_SAFE_AREA_SHELL_CLASS = 'metoyou-safe-area-shell';
/** CSS value chain for one safe-area inset (Capacitor vars with env() fallback). */
export function getSafeAreaInsetCSSValue(side: SafeAreaSide): string {
return `var(--safe-area-inset-${side}, env(safe-area-inset-${side}, 0px))`;
@@ -28,3 +39,20 @@ export function applyMobileSafeAreaDefaults(root: HTMLElement | null = typeof do
}
}
}
/** Request Capacitor Android SystemBars to recompute CSS inset variables after the SPA boots. */
export async function syncMobileSafeAreaInsets(): Promise<void> {
if (typeof window === 'undefined' || !isCapacitorNativeRuntime()) {
return;
}
const androidInterface = (window as Window & {
CapacitorSystemBarsAndroidInterface?: { onDOMReady?: () => void };
}).CapacitorSystemBarsAndroidInterface;
androidInterface?.onDOMReady?.();
const { SystemBars, SystemBarsStyle } = await import('@capacitor/core');
await SystemBars.setStyle({ style: SystemBarsStyle.Dark }).catch(() => {});
}

View File

@@ -1,6 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
import { syncMobileSafeAreaInsets } from '../logic/mobile-safe-area.rules';
import type { MobileAppLifecycleAdapter } from '../contracts/mobile.contracts';
import { WebMobileAppLifecycleAdapter } from '../adapters/web/web-mobile-app-lifecycle.adapter';
import { MobilePlatformService } from './mobile-platform.service';
@@ -24,6 +25,7 @@ export class MobileAppLifecycleService {
await adapter.initialize();
this.mobilePlatform.refreshRuntimeDetection();
await syncMobileSafeAreaInsets();
await this.runtimePermissions.initialize();
this.initialized = true;
}

View File

@@ -11,7 +11,7 @@
-->
<div
appThemeNode="bottomSheetSurface"
class="bottom-sheet-panel fixed inset-x-0 bottom-0 z-[141] flex max-h-[85vh] flex-col rounded-t-2xl border-x border-t border-border bg-card text-foreground shadow-2xl"
class="bottom-sheet-panel fixed metoyou-fixed-safe-bottom-sheet z-[141] flex flex-col rounded-t-2xl border-x border-t border-border bg-card text-foreground shadow-2xl"
[style.transform]="'translateY(' + translateY() + 'px)'"
[style.transition]="translateY() === 0 ? 'transform 200ms ease-out' : 'none'"
[attr.aria-label]="title() || ariaLabel()"

View File

@@ -1,6 +1,6 @@
@if (dismissable()) {
<div
class="fixed inset-0 bg-black/60"
class="fixed metoyou-fixed-safe-viewport bg-black/60"
[class.backdrop-blur-sm]="blur()"
[style.z-index]="zIndex()"
(click)="dismiss()"
@@ -12,7 +12,7 @@
></div>
} @else {
<div
class="fixed inset-0 bg-black/60"
class="fixed metoyou-fixed-safe-viewport bg-black/60"
[class.backdrop-blur-sm]="blur()"
[style.z-index]="zIndex()"
role="presentation"

View File

@@ -37,7 +37,10 @@
.metoyou-bottom-sheet-panel {
width: 100% !important;
max-width: 100vw;
max-height: 85vh;
max-height: calc(85vh - var(--safe-area-inset-top, env(safe-area-inset-top, 0px))) !important;
bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)) !important;
left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px)) !important;
right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px)) !important;
border-top-left-radius: 1rem;
border-top-right-radius: 1rem;
border: 1px solid hsl(var(--border));
@@ -126,12 +129,32 @@
html {
height: 100%;
}
.metoyou-safe-area-shell {
box-sizing: border-box;
height: 100%;
min-height: 100%;
padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
padding-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
padding-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
}
.metoyou-fixed-safe-viewport {
top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
}
.metoyou-fixed-safe-bottom-sheet {
right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
max-height: calc(85vh - var(--safe-area-inset-top, env(safe-area-inset-top, 0px)));
}
/*
* Keep border-box on every element (Tailwind preflight does this too). Do not use
* `box-sizing: inherit` here — it overrides preflight and lets nested hosts fall back to