From 47445b7b43002269ab535a6563f88d9f9d87ed8c Mon Sep 17 00:00:00 2001 From: "v.lagerev" Date: Mon, 11 May 2026 22:06:15 +0300 Subject: [PATCH] feat(safe-area): render edge-to-edge under Android gesture-pill / iOS home indicator and color-sync the system-bar zones to the active chat surface --- docs/ai/android.md | 4 ++-- src/app/components/page/Page.tsx | 9 +++++++++ src/app/features/room/Room.tsx | 24 +++++++++++++++++++++++- src/app/features/room/RoomView.css.ts | 14 +++++++++++++- src/app/pages/HorseshoeContainer.css.ts | 11 ++++++++--- src/app/pages/auth/styles.css.ts | 5 ++++- src/app/styles/global.css.ts | 10 ++++++++++ src/index.css | 11 +++++++++-- 8 files changed, 78 insertions(+), 10 deletions(-) diff --git a/docs/ai/android.md b/docs/ai/android.md index e3482a90..0b7c77cf 100644 --- a/docs/ai/android.md +++ b/docs/ai/android.md @@ -38,8 +38,8 @@ versionCode = major * 1_000_000 + minor * 1_000 + patch - **Service Worker stays active.** Critical for authenticated Matrix media (MSC3916 / Matrix spec v1.11+). DO NOT disable. `resolveServiceWorkerRequests` default `true`. - **Edge-to-edge.** `EdgeToEdge.enable()` in `MainActivity.java` + `windowLayoutInDisplayCutoutMode: shortEdges`. - **External links.** Opened via `@capacitor/browser` plugin — see [`src/app/utils/capacitor.ts`](../../src/app/utils/capacitor.ts). -- **Safe-area coloring.** `body` background-color is bound to the folds theme variable `var(--oq6d070)` for consistent safe-area coloring. -- **Safe-area insets.** Applied on `#root` (not `body`) so the theme background extends behind the system bars. +- **Safe-area coloring.** `body` background-color reads `--vojo-safe-area-bg` (set on `:root` in [`src/app/styles/global.css.ts`](../../src/app/styles/global.css.ts), default `#0d0e11` = chat-list tone). [`Room.tsx`](../../src/app/features/room/Room.tsx) retunes the var to `#181a20` (chat-surface tone) while a chat is mounted so the status-bar / gesture-bar zones never show a seam against the active surface. +- **Safe-area insets — top / left / right only on `#root`.** Bottom inset is intentionally **not** applied at `#root` so the app renders edge-to-edge under the Android gesture pill / 3-button bar / iOS home indicator (mirrors WhatsApp / Telegram). Components that anchor interactive UI at the screen bottom MUST add `padding-bottom: var(--vojo-safe-bottom)` themselves — covered: chat composer ([`RoomView.css.ts`](../../src/app/features/room/RoomView.css.ts)), PageNav inner column ([`Page.tsx`](../../src/app/components/page/Page.tsx) → catches SelfRow / WorkspaceFooter / etc.), bottom call rail ([`HorseshoeContainer.css.ts`](../../src/app/pages/HorseshoeContainer.css.ts)), AuthFooter ([`auth/styles.css.ts`](../../src/app/pages/auth/styles.css.ts)). New screens with a bottom CTA must follow this rule or the button lands behind a system 3-button nav bar. ## VSCode tasks diff --git a/src/app/components/page/Page.tsx b/src/app/components/page/Page.tsx index 39d9d289..2abc8005 100644 --- a/src/app/components/page/Page.tsx +++ b/src/app/components/page/Page.tsx @@ -141,6 +141,11 @@ export function PageNav({ grow="Yes" direction="Column" className={horseshoe ? css.PageNavInnerWebHorseshoe : undefined} + // Bottom inset for native: keeps any nav-footer row (SelfRow, + // WorkspaceFooter, …) clear of the Android gesture pill / 3-button + // bar / iOS home indicator after `#root` stopped reserving the + // inset itself. `var(--vojo-safe-bottom)` resolves to 0 on web. + style={{ paddingBottom: 'var(--vojo-safe-bottom)' }} > {children} @@ -268,6 +273,10 @@ function ResizablePageNav({ children }: { children: ReactNode }) { grow="Yes" direction="Column" className={horseshoe ? css.PageNavInnerWebHorseshoe : undefined} + // See twin block in `PageNav` above — same native safe-area + // protection for any footer row mounted inside a resizable + // page-nav. On web `var(--vojo-safe-bottom)` is 0. + style={{ paddingBottom: 'var(--vojo-safe-bottom)' }} > {children} diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index 9aa266a5..ccbfbe40 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { Box, Line, toRem } from 'folds'; import { useMatch, useParams } from 'react-router-dom'; import { isKeyHotkey } from 'is-hotkey'; @@ -112,6 +112,28 @@ export function Room({ renderRoomView }: RoomProps) { const profileOpen = !!useAtomValue(userRoomProfileAtom); const showProfileHorseshoe = profileOpen && !isMobile && !showThreadDrawer; + // Re-tune the Android edge-to-edge safe-area bg while a Room is on + // screen. The default value from `app/styles/global.css.ts` paints + // the chat-list tone (#0d0e11) into the status bar / nav bar zones — + // fine when the chat list is showing, but produces a visible seam + // once the chat itself (SurfaceVariant.Container, #181a20) is opened. + // + // Value is hardcoded for the SAME reason `global.css.ts` hardcodes + // `#0d0e11`: folds tokens (`color.SurfaceVariant.Container = var(--xxx)`) + // are scoped to the `.dark-theme` / `.lightTheme` class which ThemeManager + // applies to `document.body`, not to `:root`. Writing the var on `:root` + // means folds' `--xxx` doesn't resolve there and the whole declaration + // becomes invalid (body falls back to the `#0d0e11` literal in `index.css`). + // `#181a20` is `SurfaceVariant.Container` from the dark Dawn palette — + // kept in sync with `colors.css.ts` line 40 if the palette tone changes. + useEffect(() => { + const root = document.documentElement; + root.style.setProperty('--vojo-safe-area-bg', '#181a20'); + return () => { + root.style.removeProperty('--vojo-safe-area-bg'); + }; + }, []); + useKeyDown( window, useCallback( diff --git a/src/app/features/room/RoomView.css.ts b/src/app/features/room/RoomView.css.ts index a3d414c4..175044eb 100644 --- a/src/app/features/room/RoomView.css.ts +++ b/src/app/features/room/RoomView.css.ts @@ -37,7 +37,19 @@ globalStyle(`${ChatComposer} .${Editor}`, { // Top-left mirror (curve at y=19 from top is ~2.8px from left, text-left // at 28px → ~25px clearance) is even more generous, which fits the // single-line composer that grows downward when wrapping. - padding: `${toRem(6)} ${toRem(16)}`, + // + // Bottom padding ALSO carries the safe-area inset (`--vojo-safe-bottom`) + // so the action row (Plus / Smiley / Send) stays clear of the Android + // gesture pill / 3-button bar / iOS home indicator now that `#root` + // stopped reserving the inset itself (see src/index.css). The card's + // bg simply grows downward to fill the safe-area zone, which is the + // edge-to-edge pattern used by every mainstream messenger. The + // ResizeObserver in `RoomView` measures the wrap's full height + // (incl. this padding) and feeds it to `RoomTimeline`, so the + // bottom scroll-padding still ends right above the visible card + // top — no extra whitespace appears between the last message and + // the card. + padding: `${toRem(6)} ${toRem(16)} calc(${toRem(6)} + var(--vojo-safe-bottom))`, }); // Visual alignment goal: typed-text glyph-start and Plus-icon glyph-start diff --git a/src/app/pages/HorseshoeContainer.css.ts b/src/app/pages/HorseshoeContainer.css.ts index 08ed2fde..a7b61e05 100644 --- a/src/app/pages/HorseshoeContainer.css.ts +++ b/src/app/pages/HorseshoeContainer.css.ts @@ -50,13 +50,18 @@ export const appShellBottomRound = style({ // === Bottom horseshoe (call rail) === // -// Rounded *top* corners only because it sits flush against the -// safe-area inset; the bottom is the screen edge. `position: relative` -// carries the absolute-positioned orbit border. +// Rounded *top* corners only — the rail's bottom edge is the screen +// edge. `position: relative` carries the absolute-positioned orbit +// border. Bottom padding carries the native safe-area inset so the +// call action buttons (accept / decline / hang up) stay clear of the +// Android gesture pill / 3-button bar / iOS home indicator after +// `#root` stopped reserving the inset (src/index.css). The rail's bg +// continues underneath the inset for visual continuity. export const bottomRail = style({ display: 'flex', flexDirection: 'column', flexShrink: 0, + paddingBottom: 'var(--vojo-safe-bottom)', }); export const bottomRailActive = style({ diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index 35fd06aa..07cc0d02 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -271,7 +271,10 @@ export const AuthFooter = style({ alignItems: 'center', justifyContent: 'center', width: '100%', - padding: '20px 24px 10px', + // Bottom padding folds in the native safe-area inset so the footer + // text/links stay clear of the Android gesture pill / 3-button bar / + // iOS home indicator after `#root` stopped reserving the inset. + padding: '20px 24px calc(10px + var(--vojo-safe-bottom))', fontSize: '14px', color: 'rgba(232, 228, 223, 0.55)', letterSpacing: '0.02em', diff --git a/src/app/styles/global.css.ts b/src/app/styles/global.css.ts index 7b12a424..f1d98093 100644 --- a/src/app/styles/global.css.ts +++ b/src/app/styles/global.css.ts @@ -23,5 +23,15 @@ import { globalStyle } from '@vanilla-extract/css'; globalStyle(':root', { vars: { '--vojo-safe-area-bg': '#0d0e11', + // Mirror of `env(safe-area-inset-bottom)` exposed as a regular CSS + // variable so consumers don't repeat the `env(..., 0px)` fallback + // boilerplate. Used as `padding-bottom` by every component that + // anchors interactive UI at the bottom of the viewport (chat + // composer, PageNav footer rows, AuthFooter, bottom call rail). + // Required since `src/index.css` no longer adds bottom safe-area + // padding to `#root` (apps render edge-to-edge under the Android + // gesture pill / iOS home indicator). On web env() resolves to 0, + // so the variable is a no-op outside native / PWA. + '--vojo-safe-bottom': 'env(safe-area-inset-bottom, 0px)', }, }); diff --git a/src/index.css b/src/index.css index f220b67f..6922b16c 100644 --- a/src/index.css +++ b/src/index.css @@ -60,8 +60,15 @@ body { height: 100%; display: flex; flex-direction: column; - padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) - env(safe-area-inset-left); + /* Top / left / right insets keep app chrome (status icons, sidebar + rail) clear of system bars and display cutouts. Bottom inset is + intentionally zero: on Android edge-to-edge the gesture bar is a + translucent overlay, not a reserved strip — leaving #root padded + there reserves a tall band of body bg below the chat surface and + prevents the composer / call rail from reaching the screen edge. + Components that genuinely need bottom safe-area clearance (e.g. + SyncIndicator) read env(safe-area-inset-bottom) themselves. */ + padding: env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left); } *,