feat(safe-area): extend Android edge-to-edge top inset via --vojo-safe-top var and collapse profile horseshoe header with measured height

This commit is contained in:
heaven 2026-05-12 01:54:30 +03:00
parent ce82d66883
commit 149382299a
10 changed files with 232 additions and 76 deletions

View file

@ -20,7 +20,17 @@ export function Modal500({ requestClose, children }: Modal500Props) {
escapeDeactivates: stopPropagation,
}}
>
<Modal size="500" variant="Background">
<Modal
size="500"
variant="Background"
// Reset `--vojo-safe-top` for everything mounted inside the
// dialog. The Android status-bar inset is reserved by each
// page header's `padding-top: var(--vojo-safe-top)` for
// top-of-screen surfaces — but a centred 500px modal sits
// away from the screen edge, and the same padding inside it
// just adds dead space above its header.
style={{ ['--vojo-safe-top' as string]: '0px' }}
>
{/* PageRoot rendered inside the dialog (Settings,
SpaceSettings, RoomSettings) would otherwise pick up
the web horseshoe layout void column + rounded

View file

@ -141,6 +141,15 @@ export function PageNav({
grow="Yes"
direction="Column"
className={horseshoe ? css.PageNavInnerWebHorseshoe : undefined}
// Top inset for native: `#root` no longer reserves the status-bar
// height (src/index.css), so the page-nav extends to the screen
// top. The padding here pushes the page-nav header (workspace
// tabs, etc.) below the status-bar icons. Applied at the inner
// column rather than at the `PageNavHeader` recipe because the
// recipe uses a Folds `<Header size="...">` with a fixed height —
// padding there would clip the header content. `--vojo-safe-top`
// is 0 on web and inside Modal500-hosted dialogs.
style={{ paddingTop: 'var(--vojo-safe-top, 0px)' }}
>
{children}
</Box>
@ -268,6 +277,10 @@ function ResizablePageNav({ children }: { children: ReactNode }) {
grow="Yes"
direction="Column"
className={horseshoe ? css.PageNavInnerWebHorseshoe : undefined}
// Same native safe-top inset as the regular PageNav above —
// `var(--vojo-safe-top)` is 0 on web (where resizable is used)
// but kept here for symmetry / future-proofing.
style={{ paddingTop: 'var(--vojo-safe-top, 0px)' }}
>
{children}
</Box>

View file

@ -21,6 +21,20 @@ export const Shell = style([
flexDirection: 'column',
backgroundColor: color.SurfaceVariant.Container,
overflow: 'hidden',
// Native safe-top: `#root` no longer reserves the status-bar inset
// (src/index.css), so BotShell extends to the screen top. The
// padding keeps the Hero (avatar + title + actions) clear of the
// system icons. Shell bg already matches the widget body tone, so
// the padding zone reads as a continuation of the bot surface. On
// web `--vojo-safe-top` is 0.
//
// Bottom inset is intentionally NOT added here: the iframe inside
// `Frame` paints its own body bg (#181a20, see widget-telegram
// styles.css) and the widget is responsible for the gesture-pill
// clearance of its own action rows. Padding Shell here exposed a
// visible seam between the iframe area and the env-bottom-tall
// strip below it on Android.
paddingTop: 'var(--vojo-safe-top, 0px)',
},
]);

View file

@ -1,4 +1,4 @@
import React, { useCallback, useEffect } from 'react';
import React, { useCallback } from 'react';
import { Box, Line, toRem } from 'folds';
import { useMatch, useParams } from 'react-router-dom';
import { isKeyHotkey } from 'is-hotkey';
@ -112,25 +112,6 @@ export function Room({ renderRoomView }: RoomProps) {
const profileOpen = !!useAtomValue(userRoomProfileAtom);
const showProfileHorseshoe = profileOpen && !isMobile && !showThreadDrawer;
// Match the Android edge-to-edge safe-area zones (status bar + gesture
// pill) to the chat surface while a Room is mounted: `body` reads
// `--vojo-safe-area-bg`, which `app/styles/global.css.ts` defaults to
// `#0d0e11` (chat-list tone). We retune to `#181a20`
// (`SurfaceVariant.Container`, the chat tone painted by RoomView's
// `<Page>`) for the lifetime of Room and `removeProperty` on cleanup
// so the chat-list view goes back to its dark tone. Hardcoded because
// folds tokens are scoped to the `.dark-theme` class on `body` —
// writing `var(--xxx)` on `:root` (where `--vojo-safe-area-bg` lives)
// would leave it unresolved and body would fall back to the literal
// `#0d0e11` in index.css.
useEffect(() => {
const root = document.documentElement;
root.style.setProperty('--vojo-safe-area-bg', '#181a20');
return () => {
root.style.removeProperty('--vojo-safe-area-bg');
};
}, []);
useKeyDown(
window,
useCallback(
@ -173,8 +154,13 @@ export function Room({ renderRoomView }: RoomProps) {
grow="Yes"
direction="Column"
className={
showProfileHorseshoe ? ContainerColor({ variant: 'Background' }) : undefined
showProfileHorseshoe
? ContainerColor({ variant: 'Background' })
: undefined
}
// No chat-column padding-top / bg: the silhouette inside
// `MobileProfileHorseshoe` owns the safe-top inset and bg.
// See the !callView twin block below for the rationale.
>
<RoomViewProfilePanel header={<RoomViewHeader callView />}>
<Box grow="Yes">
@ -188,8 +174,18 @@ export function Room({ renderRoomView }: RoomProps) {
grow="Yes"
direction="Column"
className={
showProfileHorseshoe ? ContainerColor({ variant: 'Background' }) : undefined
showProfileHorseshoe
? ContainerColor({ variant: 'Background' })
: undefined
}
// No padding-top / bg on mobile chat column: the silhouette
// inside `MobileProfileHorseshoe` permanently sits at body_top
// with its own `padding-top: var(--vojo-safe-top)` keeping
// chat-header / panel content below the status-bar icons.
// The silhouette's bg fades between chat-surface tone (when
// closed) and user-card tone (when fully open) as the user
// drags. Desktop branch still works because `--vojo-safe-top`
// resolves to 0 on web.
>
<RoomViewProfilePanel header={<RoomViewHeader />}>
<Box grow="Yes">

View file

@ -9,8 +9,10 @@ export const HORSESHOE_GAP_PX = 12;
// Outer container — flex column hosting the silhouette (panel +
// header live inside) and the chat body below. `overflow: hidden`
// clips the chat body's rounded top corners against the container's
// own edges; `background` paints the dark «void» behind the gap
// between silhouette and chat body when the drawer is open.
// own edges when the user-card rail is open and the chatBody margin-
// top void gap opens up; without this clip the rounded-corner cut
// areas would reveal whatever's behind the chat column instead of
// the horseshoe void colour painted by `containerStyle`.
export const container = style({
position: 'relative',
display: 'flex',
@ -70,11 +72,26 @@ export const panelViewport = style({
userSelect: 'none',
});
// Anchor at the TOP of `panelViewport` so the card slides downward as
// `panelViewport.height` grows: hero avatar visible first, info rows
// behind it, drag handle last. Previously `bottom: 0` made the handle
// reveal first and the hero last — which read as "dark rising from the
// chat header" once the silhouette also extended through the status-bar
// zone above. Trade-off: drag handle is hidden mid-drag, only visible
// when fully open; the panel surface stays drag-sensitive throughout.
//
// `padding-top: var(--vojo-safe-top)` keeps the user-card content (hero
// avatar + info rows) clear of the Android status-bar icons in the
// open state. The padding zone shows the silhouette's bg
// (`Background.Container`, #0d0e11) through panelContent's transparent
// background, so the status-bar zone reads as part of the dark user
// card when the panel is open.
export const panelContent = style({
position: 'absolute',
bottom: 0,
top: 0,
left: 0,
right: 0,
paddingTop: 'var(--vojo-safe-top, 0px)',
});
export const panelInner = style({
@ -160,22 +177,46 @@ export const avatarFullFallback = style({
});
// === Header viewport === Holds the chat header as the silhouette's
// bottom child. Same CSS-grid `1fr → 0fr` row-track trick the legacy
// `headerWrap` used so the row animates smoothly without measuring
// the header's intrinsic height in JS. The inner div clips the
// header content as the track collapses. `userSelect: none` keeps a
// mouse drag-down-to-open gesture from racing native text-selection
// on the chat title — same rationale as on `panelViewport`.
// bottom child. Animated via an explicit `height` set inline (driven
// by `(1 - expandedFraction) * headerNaturalHeight`, where
// `headerNaturalHeight` is measured via `useLayoutEffect` +
// `ResizeObserver` on the inner block). The legacy
// `grid-template-rows: 1fr → 0fr` trick was abandoned because Folds
// `<Header size="600">` enforces a `min-height` that prevented the
// grid track from collapsing to 0 — leaving a light-blue strip at
// the bottom of the user card. `overflow: hidden` keeps the chat
// header content clipped while the viewport height interpolates.
// `userSelect: none` keeps a mouse drag-down-to-open gesture from
// racing native text-selection on the chat title — same rationale
// as on `panelViewport`.
export const headerViewport = style({
display: 'grid',
flexShrink: 0,
willChange: 'grid-template-rows',
overflow: 'hidden',
willChange: 'height',
userSelect: 'none',
});
// `padding-top: var(--vojo-safe-top)` pushes the chat header's content
// (back arrow, avatar, name) below the Android status-bar icons in
// edge-to-edge mode — `#root` no longer reserves the inset itself
// (src/index.css), so the chat header would otherwise land directly
// under the system icons. The padding zone is painted with
// `SurfaceVariant.Container` (the chat-header bg below it) so the
// visible strip against the status-bar icons reads as one continuous
// header band — no seam at the inset boundary.
//
// `box-sizing: border-box` is load-bearing for the height animation
// that `headerViewport` runs (see comment there): when the outer height
// reaches 0 px on full rail-open, the `padding-top` is included in the
// 0-tall box and the SurfaceVariant strip collapses with it. With
// `content-box` it would persist as a `var(--vojo-safe-top)`-tall band
// at the bottom of the user card.
export const headerViewportInner = style({
boxSizing: 'border-box',
minHeight: 0,
overflow: 'hidden',
paddingTop: 'var(--vojo-safe-top, 0px)',
backgroundColor: color.SurfaceVariant.Container,
});
// === Chat body === Timeline + input below the silhouette. The

View file

@ -18,7 +18,7 @@
// `RoomViewProfileSidePanel`, which `Room.tsx` mounts as a sibling
// of the chat column.
import React, { ReactNode, useEffect, useRef, useState } from 'react';
import React, { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useAtomValue } from 'jotai';
import { config } from 'folds';
import FocusTrap from 'focus-trap-react';
@ -38,6 +38,7 @@ import { getMxIdLocalPart, guessDmRoomUserId, mxcUrlToHttp } from '../../utils/m
import { UserRoomProfile } from '../../components/user-profile/UserRoomProfile';
import { stopPropagation } from '../../utils/keyboard';
import colorMXID from '../../../util/colorMXID';
import { VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe';
import * as css from './RoomViewProfilePanel.css';
// Card height as a fraction of the viewport.
@ -128,6 +129,19 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
const headerRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const headerInnerRef = useRef<HTMLDivElement>(null);
// Measured natural height of the chat-header inner block (incl. its
// `padding-top: var(--vojo-safe-top)`). Drives the explicit `height`
// animation on `headerViewport` — replaces the legacy `grid-template-
// rows: 1fr → 0fr` trick because Folds `<Header size="600">` enforces
// a `min-height` that prevents the grid track from collapsing fully
// to 0, leaving a light-blue strip at the bottom of the user card
// when the rail is fully open. Measured via `useLayoutEffect` so the
// first paint already uses the right value (no flicker on cold
// mount) and re-measured by ResizeObserver whenever the chat-header
// content reflows (e.g. online tag appears, env-inset changes on
// rotation).
const [headerNaturalHeight, setHeaderNaturalHeight] = useState(0);
// Close profile when the room changes — atom is global state and
// would otherwise carry the previous room's userId into this room.
@ -150,6 +164,25 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
return () => window.removeEventListener('resize', onResize);
}, []);
// Measure the chat header's natural height (incl. its safe-top padding).
// `scrollHeight` returns the content size even while the outer is
// constrained by our animated `height` — so we keep getting the right
// natural value across reflows. `useLayoutEffect` does the first
// measurement synchronously after DOM mutation, before paint, so the
// first frame already uses the measured value.
useLayoutEffect(() => {
const el = headerInnerRef.current;
if (!el) return undefined;
const measure = () => {
const next = el.scrollHeight;
if (next > 0) setHeaderNaturalHeight(next);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
const open = !!profileState;
const baseExpanded = open ? railHeightPx : 0;
const expandedPx = drag
@ -372,7 +405,7 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
: `height ${ANIMATION_MS}ms ${VAUL_EASING}`;
const headerViewportTransition = isDragging
? 'none'
: `grid-template-rows ${ANIMATION_MS}ms ${VAUL_EASING}`;
: `height ${ANIMATION_MS}ms ${VAUL_EASING}`;
const silhouetteTransition = isDragging
? 'none'
: `border-bottom-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-bottom-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`;
@ -381,7 +414,7 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
: `margin-top ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`;
const containerStyle: React.CSSProperties = {
backgroundColor: horseshoeActive ? '#090909' : undefined,
backgroundColor: horseshoeActive ? VOJO_HORSESHOE_VOID_COLOR : undefined,
};
return (
@ -482,15 +515,22 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
ref={headerRef}
className={css.headerViewport}
style={{
// Collapse the chat header to 0 height as the panel
// expands — same `1fr → 0fr` row-track trick the legacy
// layout used. CSS-grid track interpolation is animatable
// (unlike `height: auto`) and avoids measuring the
// header's intrinsic height in JS. As the row collapses,
// the silhouette's bottom edge moves from header-bottom
// up to panel-bottom; the rounded corners stay anchored
// there because they belong to the silhouette wrapper.
gridTemplateRows: `${1 - expandedFraction}fr`,
// Animate the chat header's height from its measured natural
// size down to 0 as the user-card panel expands. Linear scale
// by `(1 - expandedFraction)` so the panel and the header
// grow / shrink in lockstep. Falls back to `auto` until the
// first measurement lands (cold mount before useLayoutEffect
// runs) so the closed-state header isn't briefly squashed to
// 0. Replaces the legacy `grid-template-rows: 1fr → 0fr`
// trick because Folds `<Header size="600">` enforces a
// `min-height` that prevented the grid track from collapsing
// to 0, leaving a light-blue strip at the bottom of the
// user card. With explicit `height` + `overflow: hidden` on
// the viewport (see css), `height: 0` is forced honest.
height:
headerNaturalHeight > 0
? `${(1 - expandedFraction) * headerNaturalHeight}px`
: 'auto',
transition: headerViewportTransition,
// While the rail is mostly open the (collapsed) header
// shouldn't intercept drag-back-to-open gestures —
@ -499,7 +539,9 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
touchAction: headerDragEnabled ? 'pan-x' : undefined,
}}
>
<div className={css.headerViewportInner}>{header}</div>
<div ref={headerInnerRef} className={css.headerViewportInner}>
{header}
</div>
</div>
</div>

View file

@ -19,6 +19,16 @@ export const ThreadDrawer = style({
});
// Mobile push: drawer occupies the whole content column, not a side pane.
// `padding-top: var(--vojo-safe-top)` keeps the drawer's `ThreadDrawerHeader`
// (back arrow + thread title + counter) clear of the Android status-bar
// icons. `#root` no longer reserves the inset itself (`src/index.css`),
// and the chat-column wrapper that hosts this drawer on mobile has no
// safe-top of its own (the silhouette in `RoomViewProfilePanel` owns the
// inset for the chat header — but the thread drawer mounts as a sibling
// and would otherwise land directly under the system icons). The desktop
// `ThreadDrawer` style sits inside the chat row that has `env()` left/right
// only, so it inherits no top inset and doesn't need this padding (and
// would just leave dead space below the chat-column-wrapper's chrome).
export const ThreadDrawerMobile = style({
flexGrow: 1,
width: '100%',
@ -27,6 +37,7 @@ export const ThreadDrawerMobile = style({
flexDirection: 'column',
backgroundColor: color.Surface.Container,
minHeight: 0,
paddingTop: 'var(--vojo-safe-top, 0px)',
});
export const ThreadDrawerHeader = style({

View file

@ -18,6 +18,15 @@ export const AuthLayout = style({
// chat surface so cold start / auth read as one continuous canvas.
background: '#0d0e11',
color: '#e8e4df',
// Status-bar inset for native: `#root` no longer reserves it
// (src/index.css), so the AuthLayout backdrop now extends through the
// status-bar zone. Padding pushes the mascot / modal stack below the
// system icons. AuthLayout reads `#root`'s computed paddingTop into
// `padTop` (AuthLayout.tsx) for its anchor math; with that 0 the math
// sees the full body height and positions content against the local
// padding instead.
paddingTop: 'var(--vojo-safe-top, 0px)',
boxSizing: 'border-box',
'@media': {
'only screen and (max-width: 768px)': {

View file

@ -1,27 +1,49 @@
import { globalStyle } from '@vanilla-extract/css';
import { color } from 'folds';
// Vojo-owned safe-area background. Used by `body { background-color: ... }`
// in `src/index.css` to paint Android edge-to-edge cutout / nav-bar zones.
// Matches `Background.Container` from the Dawn palette in `src/colors.css.ts`
// (`#0d0e11`, canon DAWN.bg2). The safe-area zone reads as a continuation of
// the sidebar / nav panels — no visible color seam at the system-bar boundary.
// Android edge-to-edge top inset exposed as a plain CSS var so the
// top-anchored UIs that need it (PageNav inner column,
// `RoomViewProfilePanel.headerViewportInner` / `.panelContent`,
// `BotShell.Shell`, `AuthLayout`, `ThreadDrawerMobile`) can pad
// themselves down by exactly the status-bar height without
// re-evaluating `env(...)` at each call site. `#root` no longer
// reserves this inset itself (`src/index.css`); the chat surface /
// chat-list shell extend their own bg through the status-bar zone and
// push visible content below the system icons via this var. `Modal500`
// resets the var to `0px` inside the dialog subtree so internal
// headers don't double-pad. On web `env()` resolves to `0px`, so the
// var is a no-op there.
//
// Why a custom var (and not `color.Background.Container`):
// 1. Folds emits its color tokens scoped to a theme class (e.g. `--oq6d070`
// lives inside the `.lightTheme` selector). Before useTheme appends a
// theme class to body, `var(--oq6d070)` resolves to its initial value
// and `background-color` falls back to transparent — system bars show
// through unpainted on Android edge-to-edge. A `:root`-level var here
// resolves from the very first paint.
// 2. `--oq6d070` is a folds@2.6 internal vanilla-extract debugId hash and
// will rotate on any folds upgrade.
//
// The hardcoded #0d0e11 in `index.css` is the matching fallback for the
// instant before this CSS file is parsed.
//
// See docs/plans/dm_1x1_redesign.md §6.6 / R13.
// Bottom inset is owned per-component: surfaces that anchor
// interactive content at the screen bottom and need 3-button-nav /
// home-indicator clearance read `env(safe-area-inset-bottom)`
// themselves (e.g. `SyncIndicator.css.ts`). The chat surface /
// composer extend flush to `body_bottom` by design.
globalStyle(':root', {
vars: {
'--vojo-safe-area-bg': '#0d0e11',
'--vojo-safe-top': 'env(safe-area-inset-top, 0px)',
},
});
// Vojo-owned safe-area background. Used by `body { background-color: ... }`
// in `src/index.css` to paint the body region behind any `env()` cut-out
// zone (display notch, left/right inset) — defended in depth even though
// `#root` covers most of the layout edge-to-edge now.
//
// Bound to the folds `Background.Container` token (Dawn `#0d0e11` in
// dark; will reshade automatically when a light theme is added). The
// var lives on `body` rather than `:root` because folds attaches the
// theme class (`.dark-theme` / `.lightTheme`) to `document.body` —
// `var(--folds-token)` only resolves at scopes the theme class
// dominates. A `:root` host would freeze the var at the folds token's
// initial value (`transparent` in dark / `#fff` in light) and the
// safe-area would flash unpainted before `ThemeManager` mounts. Hosting
// on body keeps the var resolution in lockstep with whatever theme
// `ThemeManager` has applied; the literal `#0d0e11` fallback in
// `index.css`'s `body { background-color }` declaration covers the
// cold-start window before this stylesheet parses.
globalStyle('body', {
vars: {
'--vojo-safe-area-bg': color.Background.Container,
},
});

View file

@ -60,15 +60,13 @@ body {
height: 100%;
display: flex;
flex-direction: column;
/* Top / left / right insets keep app chrome clear of system bars and
display cutouts. Bottom inset is intentionally zero so the chat
surface (RoomView's `<Page>`) and the chat-list shell (PageRoot's
Background-coloured outer Box) extend all the way to the screen
edge same edge-to-edge pattern WhatsApp / Telegram use. Without
this, the bottom env zone showed a different colour from the
painted surface, and the composer's wrap-padding-bottom kept
overlapping the gesture-pill area, visually "eating" the form. */
padding: env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left);
/* Top & bottom insets are intentionally zero: each screen extends
its own bg through the system-bar zones (status bar at top, gesture
pill at bottom). Top-anchored UI pads itself down by `--vojo-safe-top`
(see `app/styles/global.css.ts`) covered: PageHeader, PageNavHeader
(chat / chat-list headers via recipe), AuthLayout root. Side insets
stay so app chrome clears display cutouts on devices with one. */
padding: 0 env(safe-area-inset-right) 0 env(safe-area-inset-left);
}
*,