diff --git a/agents-docs/features/mobile-capacitor.md b/agents-docs/features/mobile-capacitor.md
index 4092ffe..9058d15 100644
--- a/agents-docs/features/mobile-capacitor.md
+++ b/agents-docs/features/mobile-capacitor.md
@@ -201,9 +201,10 @@ The service shows a low-importance ongoing notification while a call is active.
## Safe area (Android)
-- Capacitor `SystemBars` injects `--safe-area-inset-*` CSS variables into `document.documentElement`. `index.html` sets `viewport-fit=cover` and default inset values; `main.ts` calls `applyMobileSafeAreaDefaults()` so injection never hits a missing root element after the WebView loads.
+- Capacitor `SystemBars` injects `--safe-area-inset-*` CSS variables into `document.documentElement`. `index.html` sets `viewport-fit=cover` and default inset values; `main.ts` calls `applyMobileSafeAreaDefaults()` so injection never hits a missing root element after the WebView loads. `MobileAppLifecycleService` calls `syncMobileSafeAreaInsets()` after Capacitor boot so Android SystemBars recomputes inset variables once the SPA is ready.
- `capacitor.config.ts` sets `plugins.SystemBars.insetsHandling: 'css'` so Android WebView versions that mis-report `env(safe-area-inset-*)` still receive correct insets.
-- Global `styles.scss` applies inset padding on `html` (with `env()` fallback) and sizes `app-root` to `height: 100%` so content stays below the status bar and above the navigation bar in edge-to-edge mode.
+- Global `styles.scss` defines `metoyou-safe-area-shell` (mobile app shell padding), `metoyou-fixed-safe-viewport` (full-screen modals/backdrops), and `metoyou-fixed-safe-bottom-sheet` (bottom sheets and CDK profile-card panels). These read `--safe-area-inset-*` with `env()` fallback so routed pages, settings, context menus, and profile cards stay below the status bar and above the navigation bar.
+- Android `styles.xml` uses transparent status/navigation bars and `windowLayoutInDisplayCutoutMode=shortEdges` so Capacitor can draw edge-to-edge and report accurate insets.
## Self-hosted HTTPS signal servers (Android)
diff --git a/toju-app/android/app/src/main/res/values/styles.xml b/toju-app/android/app/src/main/res/values/styles.xml
index be874e5..3a23078 100644
--- a/toju-app/android/app/src/main/res/values/styles.xml
+++ b/toju-app/android/app/src/main/res/values/styles.xml
@@ -2,21 +2,31 @@
-
-
\ No newline at end of file
+
diff --git a/toju-app/src/app/app.html b/toju-app/src/app/app.html
index c69f08c..5082a3d 100644
--- a/toju-app/src/app/app.html
+++ b/toju-app/src/app/app.html
@@ -1,6 +1,7 @@
-
+
({
+ 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
();
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' });
+ });
});
diff --git a/toju-app/src/app/infrastructure/mobile/logic/mobile-safe-area.rules.ts b/toju-app/src/app/infrastructure/mobile/logic/mobile-safe-area.rules.ts
index 7f1fa08..b7d68a5 100644
--- a/toju-app/src/app/infrastructure/mobile/logic/mobile-safe-area.rules.ts
+++ b/toju-app/src/app/infrastructure/mobile/logic/mobile-safe-area.rules.ts
@@ -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 {
+ 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(() => {});
+}
diff --git a/toju-app/src/app/infrastructure/mobile/services/mobile-app-lifecycle.service.ts b/toju-app/src/app/infrastructure/mobile/services/mobile-app-lifecycle.service.ts
index 383a64b..d561cf8 100644
--- a/toju-app/src/app/infrastructure/mobile/services/mobile-app-lifecycle.service.ts
+++ b/toju-app/src/app/infrastructure/mobile/services/mobile-app-lifecycle.service.ts
@@ -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;
}
diff --git a/toju-app/src/app/shared/components/bottom-sheet/bottom-sheet.component.html b/toju-app/src/app/shared/components/bottom-sheet/bottom-sheet.component.html
index 9e8a386..4411d09 100644
--- a/toju-app/src/app/shared/components/bottom-sheet/bottom-sheet.component.html
+++ b/toju-app/src/app/shared/components/bottom-sheet/bottom-sheet.component.html
@@ -11,7 +11,7 @@
-->
} @else {