From ce82d66883f8f7a0f31bbd6755189c687c438602 Mon Sep 17 00:00:00 2001 From: heaven Date: Mon, 11 May 2026 23:13:56 +0300 Subject: [PATCH 01/87] feat(safe-area): paint Android edge-to-edge top/bottom strips with the active surface tone instead of a fixed body bg --- src/app/features/room/Room.tsx | 21 ++++++++++++++++++++- src/index.css | 11 +++++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index 9aa266a5..80919810 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,25 @@ 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 + // ``) 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( diff --git a/src/index.css b/src/index.css index f220b67f..656e09bf 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 clear of system bars and + display cutouts. Bottom inset is intentionally zero so the chat + surface (RoomView's ``) 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); } *, From 149382299a3ab072767c0662b2761bc31ae90d49 Mon Sep 17 00:00:00 2001 From: heaven Date: Tue, 12 May 2026 01:54:30 +0300 Subject: [PATCH 02/87] feat(safe-area): extend Android edge-to-edge top inset via --vojo-safe-top var and collapse profile horseshoe header with measured height --- src/app/components/Modal500.tsx | 12 +++- src/app/components/page/Page.tsx | 13 ++++ src/app/features/bots/BotShell.css.ts | 14 ++++ src/app/features/room/Room.tsx | 40 +++++------ .../features/room/RoomViewProfilePanel.css.ts | 63 ++++++++++++++--- .../features/room/RoomViewProfilePanel.tsx | 68 +++++++++++++++---- src/app/features/room/ThreadDrawer.css.ts | 11 +++ src/app/pages/auth/styles.css.ts | 9 +++ src/app/styles/global.css.ts | 62 +++++++++++------ src/index.css | 16 ++--- 10 files changed, 232 insertions(+), 76 deletions(-) diff --git a/src/app/components/Modal500.tsx b/src/app/components/Modal500.tsx index a4c3128c..43dca162 100644 --- a/src/app/components/Modal500.tsx +++ b/src/app/components/Modal500.tsx @@ -20,7 +20,17 @@ export function Modal500({ requestClose, children }: Modal500Props) { escapeDeactivates: stopPropagation, }} > - + {/* PageRoot rendered inside the dialog (Settings, SpaceSettings, RoomSettings) would otherwise pick up the web horseshoe layout — void column + rounded diff --git a/src/app/components/page/Page.tsx b/src/app/components/page/Page.tsx index 39d9d289..4e16b8ff 100644 --- a/src/app/components/page/Page.tsx +++ b/src/app/components/page/Page.tsx @@ -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 `
` 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} @@ -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} diff --git a/src/app/features/bots/BotShell.css.ts b/src/app/features/bots/BotShell.css.ts index 723cc9cf..dfdeb291 100644 --- a/src/app/features/bots/BotShell.css.ts +++ b/src/app/features/bots/BotShell.css.ts @@ -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)', }, ]); diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index 80919810..96486cdb 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -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 - // ``) 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. > }> @@ -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. > }> diff --git a/src/app/features/room/RoomViewProfilePanel.css.ts b/src/app/features/room/RoomViewProfilePanel.css.ts index 3831f24f..c9401b8f 100644 --- a/src/app/features/room/RoomViewProfilePanel.css.ts +++ b/src/app/features/room/RoomViewProfilePanel.css.ts @@ -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 +// `
` 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 diff --git a/src/app/features/room/RoomViewProfilePanel.tsx b/src/app/features/room/RoomViewProfilePanel.tsx index 97cac006..a29dd542 100644 --- a/src/app/features/room/RoomViewProfilePanel.tsx +++ b/src/app/features/room/RoomViewProfilePanel.tsx @@ -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(null); const panelRef = useRef(null); + const headerInnerRef = useRef(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 `
` 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 `
` 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, }} > -
{header}
+
+ {header} +
diff --git a/src/app/features/room/ThreadDrawer.css.ts b/src/app/features/room/ThreadDrawer.css.ts index e7995430..9674ef64 100644 --- a/src/app/features/room/ThreadDrawer.css.ts +++ b/src/app/features/room/ThreadDrawer.css.ts @@ -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({ diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index 35fd06aa..9551731a 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -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)': { diff --git a/src/app/styles/global.css.ts b/src/app/styles/global.css.ts index 7b12a424..f578a87a 100644 --- a/src/app/styles/global.css.ts +++ b/src/app/styles/global.css.ts @@ -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, }, }); diff --git a/src/index.css b/src/index.css index 656e09bf..026fe438 100644 --- a/src/index.css +++ b/src/index.css @@ -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 ``) 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); } *, From c6bb66958d4cf888388fd388a496b4a8640aca78 Mon Sep 17 00:00:00 2001 From: heaven Date: Tue, 12 May 2026 02:06:10 +0300 Subject: [PATCH 03/87] feat(profile-rail): size mobile user-card rail to measured content height with 85vh cap and inner scroll only on overflow --- .../features/room/RoomViewProfilePanel.css.ts | 28 ++++-- .../features/room/RoomViewProfilePanel.tsx | 95 +++++++++++++++---- 2 files changed, 99 insertions(+), 24 deletions(-) diff --git a/src/app/features/room/RoomViewProfilePanel.css.ts b/src/app/features/room/RoomViewProfilePanel.css.ts index c9401b8f..72de6ab8 100644 --- a/src/app/features/room/RoomViewProfilePanel.css.ts +++ b/src/app/features/room/RoomViewProfilePanel.css.ts @@ -86,11 +86,19 @@ export const panelViewport = style({ // (`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. +// +// `box-sizing: border-box` makes the inline `height: ${railHeightPx}` +// include the safe-top padding — so the rail measured in +// `RoomViewProfilePanel.tsx` (content + padTop + handle) exactly +// matches the actual visible height. Without `border-box` the height +// would be content-area only and the rail would visually overshoot by +// `var(--vojo-safe-top)`, leaving an unfilled gap at the bottom. export const panelContent = style({ position: 'absolute', top: 0, left: 0, right: 0, + boxSizing: 'border-box', paddingTop: 'var(--vojo-safe-top, 0px)', }); @@ -100,16 +108,22 @@ export const panelInner = style({ height: '100%', }); -// Functional overflow without a visible scrollbar. The card's -// content (hero + ~4 info rows + chip row) almost always fits inside -// the rail height, but moderation alerts can push it past — we keep -// the panel scrollable for that case while suppressing the -// scrollbar chrome (it's not a useful affordance on a 42vh rail and -// the user explicitly asked us to drop it). +// Holds the user-card content. `overflow-y` is driven inline from +// `RoomViewProfilePanel.tsx` based on whether the measured content +// height exceeds the safety cap (`MAX_RAIL_FRACTION × viewport`): +// +// • Common fit case (content ≤ cap) → `overflow-y: hidden`. The +// rail is sized to content, so there's literally nothing to +// scroll. This is the user's explicit «запретил бы драгу внутри» +// ask — no drag-inside-drag inside the user card. +// • Rare overflow case (e.g. stacked moderation alerts) → switches +// to `auto`, so the user can still reach the clipped tail. +// +// Scrollbar chrome is suppressed either way; the auto case relies on +// the standard touch / wheel scroll affordances. export const panelScroll = style({ flex: 1, minHeight: 0, - overflow: 'auto', scrollbarWidth: 'none', selectors: { '&::-webkit-scrollbar': { diff --git a/src/app/features/room/RoomViewProfilePanel.tsx b/src/app/features/room/RoomViewProfilePanel.tsx index a29dd542..73da4daa 100644 --- a/src/app/features/room/RoomViewProfilePanel.tsx +++ b/src/app/features/room/RoomViewProfilePanel.tsx @@ -41,8 +41,26 @@ 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. -const RAIL_HEIGHT_FRACTION = 0.42; +// Rail-height policy: the card sizes itself to its actual content +// height (hero + info rows + optional alerts + safe-top inset + drag +// handle), measured via useLayoutEffect + ResizeObserver below. The +// internal scroll has been removed (user request: «запретил бы драгу +// внутри карточки») so there is no drag-inside-drag — the rail simply +// grows or shrinks with whatever the profile renders. +// +// `MAX_RAIL_FRACTION` is a defensive ceiling for the rare overflow +// case (e.g. 5 stacked moderation alerts) — content beyond it is +// clipped by `panelViewport`'s `overflow: hidden`. 0.85 keeps a +// couple of chat lines peeking at the bottom so the user still has +// context that the chat is behind the card. +// +// `FALLBACK_RAIL_FRACTION` is the size used for one frame before the +// content measurement lands. useLayoutEffect runs synchronously before +// browser paint, so in practice the user only ever sees the measured +// height — but the constant matters during SSR / before the effect +// commits state. +const MAX_RAIL_FRACTION = 0.85; +const FALLBACK_RAIL_FRACTION = 0.42; // Past this many pixels of drag the gesture commits (open or close). const COMMIT_THRESHOLD_PX = 80; const ANIMATION_MS = 250; @@ -152,18 +170,54 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps) setAvatarMode(false); }, [profileState?.userId]); - const [railHeightPx, setRailHeightPx] = useState(() => { - if (typeof window === 'undefined') return 400; - return Math.round(window.innerHeight * RAIL_HEIGHT_FRACTION); + const [viewportHeight, setViewportHeight] = useState(() => { + if (typeof window === 'undefined') return 800; + return window.innerHeight; }); useEffect(() => { - const onResize = () => { - setRailHeightPx(Math.round(window.innerHeight * RAIL_HEIGHT_FRACTION)); - }; + const onResize = () => setViewportHeight(window.innerHeight); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); + // Measured natural content height of the user card (S400-padded + // wrapper around `UserRoomProfile`) plus the panelContent's safe-top + // padding and the panelHandle. Drives `railHeightPx` so the rail + // exactly contains the content — no internal scroll in the fit case. + // Re-measures when the profile reflows (alerts mount, encryption + // detected, presence label changes, etc.). + const [contentNaturalHeight, setContentNaturalHeight] = useState(0); + const panelContentRef = useRef(null); + const panelMeasureRef = useRef(null); + useLayoutEffect(() => { + const measureEl = panelMeasureRef.current; + const contentEl = panelContentRef.current; + if (!measureEl || !contentEl) return undefined; + const measure = () => { + const innerH = measureEl.scrollHeight; + if (innerH <= 0) return; + const padTop = parseFloat(getComputedStyle(contentEl).paddingTop) || 0; + // 20 px must stay in sync with `panelHandle.height` in css.ts. + setContentNaturalHeight(innerH + padTop + 20); + }; + measure(); + const ro = new ResizeObserver(measure); + ro.observe(measureEl); + return () => ro.disconnect(); + }, []); + + const maxRailPx = Math.round(viewportHeight * MAX_RAIL_FRACTION); + const railHeightPx = + contentNaturalHeight > 0 + ? Math.min(maxRailPx, contentNaturalHeight) + : Math.round(viewportHeight * FALLBACK_RAIL_FRACTION); + // Internal scroll is enabled ONLY when the rail hit the safety cap + // (very rare — multiple stacked moderation alerts). In the common + // case the rail is exactly content-sized and there is nothing to + // scroll — `overflow: hidden` then prevents any drag-inside-drag. + const contentOverflows = + contentNaturalHeight > 0 && contentNaturalHeight > maxRailPx; + // 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 @@ -457,6 +511,7 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps) }} >
@@ -485,15 +540,21 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps) ) : (
- {/* No visible scrollbar — the card content (hero + - ~4 info rows + a chip row) almost always fits, - and on the rare overflow case (lots of - moderation alerts) we keep functional scrolling - via the `panelScroll` class which sets - `overflow: auto` + `scrollbar-width: none` / - `::-webkit-scrollbar` hidden. */} -
-
+ {/* The rail is sized to the measured content height + (see `contentNaturalHeight` above), capped at + `MAX_RAIL_FRACTION × viewport`. In the common + fit case `overflow: hidden` prevents any + drag-inside-drag — there's literally nothing to + scroll. Only when the cap clips real content + (rare — many stacked moderation alerts) do we + switch to `auto` so the user can reach the + hidden tail. Scrollbar chrome stays suppressed + via the css class either way. */} +
+
{renderUserId && ( Date: Wed, 13 May 2026 00:01:26 +0300 Subject: [PATCH 04/87] feat(settings): replace Modal500 with /settings route plus mobile bottom-up horseshoe sheet overlaying DM list via clip-path mask --- public/locales/en.json | 2 + public/locales/ru.json | 2 + src/app/components/page/Page.tsx | 73 ++- src/app/components/page/style.css.ts | 8 + .../settings/MobileSettingsHorseshoe.css.ts | 176 ++++++ .../settings/MobileSettingsHorseshoe.tsx | 558 ++++++++++++++++++ src/app/features/settings/Settings.tsx | 188 ++++-- src/app/features/settings/SettingsScreen.tsx | 129 ++++ src/app/features/settings/about/About.tsx | 10 +- src/app/features/settings/account/Account.tsx | 6 +- .../features/settings/account/ContactInfo.tsx | 2 +- .../settings/account/IgnoredUserList.tsx | 2 +- .../features/settings/account/MatrixId.tsx | 2 +- src/app/features/settings/account/Profile.tsx | 2 +- .../settings/developer-tools/AccountData.tsx | 2 +- .../settings/developer-tools/DevelopTools.tsx | 10 +- .../features/settings/devices/DeviceTile.tsx | 2 +- src/app/features/settings/devices/Devices.tsx | 10 +- .../features/settings/devices/LocalBackup.tsx | 4 +- .../settings/devices/OtherDevices.tsx | 2 +- .../settings/devices/Verification.tsx | 2 +- .../emojis-stickers/EmojisStickers.tsx | 6 +- .../settings/emojis-stickers/GlobalPacks.tsx | 4 +- .../settings/emojis-stickers/UserPack.tsx | 2 +- src/app/features/settings/general/General.tsx | 32 +- src/app/features/settings/index.ts | 2 + .../settings/notifications/AllMessages.tsx | 8 +- .../notifications/KeywordMessages.tsx | 4 +- .../settings/notifications/Notifications.tsx | 8 +- .../notifications/SpecialMessages.tsx | 10 +- .../notifications/SystemNotification.tsx | 8 +- src/app/pages/Router.tsx | 23 + src/app/pages/client/direct/Direct.tsx | 154 ++--- src/app/pages/client/direct/DirectSelfRow.tsx | 181 +++--- src/app/pages/client/sidebar/SettingsTab.tsx | 35 +- .../pages/client/sidebar/UnverifiedTab.tsx | 82 +-- src/app/pages/pathUtils.ts | 8 + src/app/pages/paths.ts | 16 + src/app/state/hooks/settingsSheet.ts | 21 + src/app/state/settingsSheet.ts | 15 + 40 files changed, 1478 insertions(+), 333 deletions(-) create mode 100644 src/app/features/settings/MobileSettingsHorseshoe.css.ts create mode 100644 src/app/features/settings/MobileSettingsHorseshoe.tsx create mode 100644 src/app/features/settings/SettingsScreen.tsx create mode 100644 src/app/state/hooks/settingsSheet.ts create mode 100644 src/app/state/settingsSheet.ts diff --git a/public/locales/en.json b/public/locales/en.json index 3f5a2d9a..10cc2995 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -90,6 +90,8 @@ "menu_emojis_stickers": "Emojis & Stickers", "menu_developer_tools": "Developer Tools", "menu_about": "About", + "drag_to_close": "Drag down to close", + "close": "Close settings", "logout": "Logout", "logout_confirm": "You're about to log out. Are you sure?", "logout_failed": "Failed to logout! {{message}}", diff --git a/public/locales/ru.json b/public/locales/ru.json index 3939bd0b..e7efb3d9 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -90,6 +90,8 @@ "menu_emojis_stickers": "Эмодзи и стикеры", "menu_developer_tools": "Инструменты разработчика", "menu_about": "О приложении", + "drag_to_close": "Потянуть вниз чтобы закрыть", + "close": "Закрыть настройки", "logout": "Выйти", "logout_confirm": "Вы собираетесь выйти из аккаунта. Вы уверены?", "logout_failed": "Не удалось выйти! {{message}}", diff --git a/src/app/components/page/Page.tsx b/src/app/components/page/Page.tsx index 4e16b8ff..ca923d5e 100644 --- a/src/app/components/page/Page.tsx +++ b/src/app/components/page/Page.tsx @@ -9,7 +9,7 @@ import React, { useRef, useState, } from 'react'; -import { Box, Header, Line, Scroll, Text, as, toRem } from 'folds'; +import { Box, Header, Line, Scroll, Text, as, color, toRem } from 'folds'; import { useAtom } from 'jotai'; import classNames from 'classnames'; import { ContainerColor } from '../../styles/ContainerColor.css'; @@ -117,10 +117,29 @@ export function PageRoot({ nav, children }: PageRootProps) { type ClientDrawerLayoutProps = { children: ReactNode; resizable?: boolean; + // Round the inner column's right-side corners (TR + BR). Off by + // default — the page-nav right rounding was intentionally dropped + // from the standard pattern in commit 74d32eb. Re-enabled only at + // callsites that explicitly opt in. Currently unused in the tree + // (the Settings nav tried it and was reverted on product feedback), + // kept as a primitive for future nested-horseshoe surfaces that + // want a fully-rounded "island" nav between two voids. + roundedRight?: boolean; + // Background surface tone for the inner column. Default + // `'background'` reads the standard `Background.Container` (Dawn + // bg2 = #0d0e11) — the deepest surface, what every tab's nav uses. + // `'surfaceVariant'` swaps to `color.SurfaceVariant.Container` + // (Dawn bg = #181a20) — the «raised» chat-pane tone used by 1-1 + // chats and the composer; visually a step lighter than the DM + // list, marking the Settings nav as a distinct surface without + // jumping outside the Dawn palette. + surface?: 'background' | 'surfaceVariant'; }; export function PageNav({ size, resizable, + roundedRight, + surface, children, }: ClientDrawerLayoutProps & css.PageNavVariants) { const screenSize = useScreenSizeContext(); @@ -131,6 +150,20 @@ export function PageNav({ return {children}; } + const radii = toRem(VOJO_HORSESHOE_RADIUS_PX); + const roundedRightStyle = + roundedRight && horseshoe + ? { borderTopRightRadius: radii, borderBottomRightRadius: radii } + : undefined; + // Inline `backgroundColor` overrides whatever `PageNavInnerWebHorseshoe` + // sets via vanilla-extract — inline style wins on specificity, so + // we can override the default `Background.Container` without + // touching the recipe. + const surfaceStyle = + surface === 'surfaceVariant' + ? { backgroundColor: color.SurfaceVariant.Container } + : undefined; + return ( ` 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)' }} + style={{ + paddingTop: 'var(--vojo-safe-top, 0px)', + ...roundedRightStyle, + ...surfaceStyle, + }} > {children} @@ -362,15 +399,29 @@ export function PageNavContent({ ); } -export const Page = as<'div'>(({ className, ...props }, ref) => ( - -)); +type PageVariantProps = { + // Background surface tone. Default `'Surface'` (Dawn bg2, #0d0e11) + // — the deepest tone used by every sub-page elsewhere in the app. + // `'SurfaceVariant'` (Dawn bg, #181a20) is one notch lighter and + // used by the Settings sub-pages so they read on the same surface + // tone as the Settings menu (which itself uses + // `surface="surfaceVariant"` on its PageNav). Other variants + // (`'Background'`, `'Primary'`, etc.) pass through unchanged in + // case a future surface needs a different tone — see folds tokens + // for the full set. + variant?: 'Background' | 'Surface' | 'SurfaceVariant' | 'Primary' | 'Secondary'; +}; +export const Page = as<'div', PageVariantProps>( + ({ className, variant = 'Surface', ...props }, ref) => ( + + ) +); export const PageHeader = as<'div', css.PageHeaderVariants>( ({ className, outlined, balance, ...props }, ref) => ( diff --git a/src/app/components/page/style.css.ts b/src/app/components/page/style.css.ts index 8a60bedb..7c35d4ab 100644 --- a/src/app/components/page/style.css.ts +++ b/src/app/components/page/style.css.ts @@ -79,6 +79,14 @@ export const PageNav = recipe({ '300': { width: toRem(222), }, + // Used by the Settings nav — ~1.43× the regular 300 (~317px = + // 1.3 × 1.1 over 222px). Settings labels are long + // ("Notifications", "Emojis & Stickers", "Developer Tools") and + // the 222px column truncated them; the wider column also gives + // the nested-horseshoe void gap on the right room to breathe. + '350': { + width: toRem(317), + }, }, }, defaultVariants: { diff --git a/src/app/features/settings/MobileSettingsHorseshoe.css.ts b/src/app/features/settings/MobileSettingsHorseshoe.css.ts new file mode 100644 index 00000000..dab35247 --- /dev/null +++ b/src/app/features/settings/MobileSettingsHorseshoe.css.ts @@ -0,0 +1,176 @@ +import { style } from '@vanilla-extract/css'; +import { color, toRem } from 'folds'; +import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe'; + +// Re-exported so the TSX can pick up the constants without crossing +// the vanilla-extract / runtime boundary twice. +export const HORSESHOE_RADIUS_PX = VOJO_HORSESHOE_RADIUS_PX; +export const HORSESHOE_GAP_PX = VOJO_HORSESHOE_GAP_PX; + +// Outer container — `position: relative` anchor for the two absolutely- +// positioned panes (`appBody` and `silhouette`). `overflow: hidden` +// clips anything that overflows the wrapper's bounds and crops the +// rounded carves on both panes against the container's bg (which is +// painted with `VOJO_HORSESHOE_VOID_COLOR` inline when the sheet is +// active, so the carved-out areas read as the same near-black seam +// used everywhere else in the app). +// +// `flex: 1` so the container fills whatever flex slot it's mounted in +// (PageNav's inner column for the Direct route). +export const container = style({ + position: 'relative', + display: 'flex', + flex: 1, + flexDirection: 'column', + minWidth: 0, + minHeight: 0, + overflow: 'hidden', +}); + +// === App body === Holds the wrapped children (the DM list — header, +// scroll content, DirectNewChatRow, DirectSelfRow). Fills the +// container via `inset: 0`. Does NOT translate or shrink — the DM +// list stays exactly where it was in the closed state. Instead, the +// bottom of the pane is masked away by an animated `clip-path: +// inset(...)` with rounded BL/BR corners — the user sees the visible +// top portion of the DM list with a rounded carve at the new bottom +// edge, exactly like the profile horseshoe shows the chat with a +// rounded TOP carve as the panel masks it from above. +// +// Why clip-path rather than flex-shrink + margin-bottom: a flex- +// shrink approach changes the scroll-container's height every render, +// and the DM list's `@tanstack/react-virtual` re-measures items mid- +// gesture. Why not `transform: translateY` either: translating moves +// the whole pane up off the top of the viewport, including the +// DirectStreamHeader the user wants to keep visible. Clip-path leaves +// layout unchanged — the DM list keeps its scroll position, its +// measured heights, and its top items in place; only the bottom edge +// of what's visible gets carved into the void below. +// +// `backgroundColor: Background.Container` is load-bearing: the +// container behind appBody is painted with the void colour (#090909) +// when the sheet is active, and without an opaque bg here the void +// would bleed through every transparent gap between DM list rows. +// The clip-path then carves away the bottom of this opaque pane, +// exposing the container's void colour only in the masked region — +// the only place we want the void to show. +// +// `flex: column` so the children (which expect a flex column parent +// — PageNav uses it) still stack naturally. +export const appBody = style({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + display: 'flex', + flexDirection: 'column', + minWidth: 0, + minHeight: 0, + backgroundColor: color.Background.Container, + willChange: 'clip-path', +}); + +// === Silhouette === The Settings sheet's surface. Anchored at the +// bottom of the container; its height animates 0 → railHeight as the +// user drags up. Rounded TL/TR carve the top edge against the void +// gap between it and the translated-up appBody. +// +// `overflow: hidden` clips `panelContent` (which is railHeight tall, +// top-anchored) so the visible portion of the panel is just the +// silhouette's current height — the user sees more of the panel +// content reveal from the top as silhouette grows. +// +// Background: `SurfaceVariant.Container` (Dawn bg = #181a20) — the +// chat-pane tone, same as the Settings PageNav inside (set via +// `surface="surfaceVariant"`). Load-bearing: the silhouette's bg +// shows through every panel gap that Settings's PageNav doesn't +// cover — the 20px drag-handle band at the top and the +// `env(safe-area-inset-bottom)` padding inside `panelContent` at the +// gesture-pill strip. With `Background.Container` (#0d0e11) here, the +// user saw dark stripes at both seams on Samsung S24 edge-to-edge; +// matching silhouette to the PageNav tone closes them. Same idea as +// commit 77bb72d which dynamically retunes `--vojo-safe-area-bg` +// while a Room is mounted to keep the system-bar strips and the chat +// surface in lockstep. +export const silhouette = style({ + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + backgroundColor: color.SurfaceVariant.Container, + willChange: 'height, border-top-left-radius, border-top-right-radius', +}); + +// Anchored at the TOP of `silhouette` so as silhouette grows from 0 +// upward, more of the panel content reveals from the top down. The +// handle and Settings title are at the top of `panelContent`, so they +// are visible at the very first pixel of drag — gives the user +// immediate "I'm opening Settings" feedback. +// +// `padding-bottom: env(safe-area-inset-bottom)` keeps the panel +// content (Settings menu) clear of the Android nav bar in edge-to-edge +// mode. `box-sizing: border-box` makes the inline `height: railHeightPx` +// include the inset, so the panel's measured height includes the +// reserved inset and the bottom of the visible content sits above the +// system icons. +export const panelContent = style({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + boxSizing: 'border-box', + display: 'flex', + flexDirection: 'column', + paddingBottom: 'env(safe-area-inset-bottom, 0px)', +}); + +// Drag handle band — sits at the TOP of `panelContent` so it appears +// at the top edge of the emerging sheet as the user drags up. Mirror +// of the profile horseshoe handle (20px tall + 36×4 grabber). The +// whole band is the ONLY drag-to-close target — touches on the +// panel body below this strip do NOT initiate drag, so Settings's +// internal scroll works without conflict. +// +// `touchAction: none` ensures the browser doesn't try to scroll +// when the user drags on the handle (the whole gesture is ours). +export const panelHandle = style({ + flexShrink: 0, + height: toRem(20), + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'grab', + touchAction: 'none', + userSelect: 'none', + selectors: { + '&:active': { cursor: 'grabbing' }, + }, +}); + +export const panelHandleBar = style({ + width: toRem(36), + height: toRem(4), + borderRadius: toRem(4), + // Darker than the silhouette's SurfaceVariant bg so the grabber + // reads against the panel without competing with content. The + // Dawn `Background.Container` (#0d0e11) is one notch deeper than + // `SurfaceVariant.Container` (#181a20) — same step-down used by + // chat bubbles vs the chat surface. + backgroundColor: color.Background.Container, +}); + +// Holds the Settings tree. `flex: 1` so it grows to fill the +// remaining `panelContent` height below the handle. No drag listeners +// here — Settings's own internal scroll handles touch events; the +// drag-to-close gesture is reserved for `panelHandle`. +export const panelBody = style({ + flex: 1, + display: 'flex', + flexDirection: 'column', + minHeight: 0, + minWidth: 0, +}); diff --git a/src/app/features/settings/MobileSettingsHorseshoe.tsx b/src/app/features/settings/MobileSettingsHorseshoe.tsx new file mode 100644 index 00000000..b32239c0 --- /dev/null +++ b/src/app/features/settings/MobileSettingsHorseshoe.tsx @@ -0,0 +1,558 @@ +// Bottom-up «horseshoe» sheet that wraps the mobile Direct DM list. +// Mirror of `MobileProfileHorseshoe` in features/room — the chat +// there is wrapped by a top-down horseshoe (panel above, chat below +// with a 12px void). Here we invert: the wrapped app body is above, +// the Settings sheet emerges from below, and a 12px void separates +// them in a )|( silhouette. +// +// User-visible behaviour: +// +// • The wrapped app body (DirectStreamHeader → DM list → new-chat +// row → DirectSelfRow) stays exactly where it was — no translate, +// no shrink. The bottom of the visible portion is "masked away" +// by an animated `clip-path: inset(0 0 BOTTOMpx 0 round 0 0 Rpx +// Rpx)` with rounded BL/BR carves at the new visible edge. The +// carved area exposes the container's void colour underneath, and +// the silhouette below covers the rest of the masked zone. Why +// not `transform: translateY`: translating moves the TOP of the +// pane out of the viewport (DirectStreamHeader scrolls off- +// screen). Why not `flex-shrink + margin-bottom`: the virtualized +// DM list (`@tanstack/react-virtual`) re-measures items every +// time the scroll container resizes — items above the shrinking +// edge visibly smear. Clip-path leaves layout unchanged: the DM +// list keeps its scroll position, its measured heights, and its +// top items in place; only the bottom edge of what's visible is +// carved into the void. +// • Drag-up origin is `DirectSelfRow` itself, marked with the +// `data-settings-drag-origin` attribute. A document-level +// touchstart / pointerdown listener uses `target.closest()` to +// detect drags starting inside the row, so the row's own click +// handler keeps working (tap without movement → openSheet via +// atom; tap with movement → drag). +// • Drag-to-close origin is ONLY the 20px handle band at the top +// of the sheet. Settings's internal content (menu, sub-pages, +// scrolling list) is NOT drag-sensitive, so the user can scroll +// through Settings without accidentally dismissing the sheet. +// +// State is Jotai-atom-driven (`settingsSheetAtom`) so the sheet +// overlays the DM list without route-swapping it away. The /settings +// URL still exists as a deep-link entry — see `SettingsScreen.tsx` +// for the desktop branch and the mobile-side redirect that sets the +// atom and bounces to /direct/. +// +// NO FocusTrap: focus-trap-react throws when its container has no +// tabbable nodes, which is exactly the case mid-drag before Settings +// has rendered any focusable children. Esc handling is an explicit +// `keydown` listener; touch / pointer driving the gesture means +// focus-trap semantics aren't load-bearing here anyway. + +import React, { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useAtomValue } from 'jotai'; +import { useTranslation } from 'react-i18next'; +import { settingsSheetAtom } from '../../state/settingsSheet'; +import { + useCloseSettingsSheet, + useOpenSettingsSheet, +} from '../../state/hooks/settingsSheet'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { HorseshoeEnabledContext } from '../../components/page'; +import { VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe'; +import { Settings } from './Settings'; +import * as css from './MobileSettingsHorseshoe.css'; + +// Data attribute set on `DirectSelfRow` to mark it as the drag-up +// origin for the sheet. Anything with `.closest(SELECTOR)` matching is +// treated as the open-drag handle. Keep in sync with `DirectSelfRow.tsx`. +export const SETTINGS_SHEET_DRAG_ORIGIN_ATTR = 'data-settings-drag-origin'; +const DRAG_ORIGIN_SELECTOR = `[${SETTINGS_SHEET_DRAG_ORIGIN_ATTR}]`; + +const VAUL_EASING = 'cubic-bezier(0.32, 0.72, 0, 1)'; +const ANIMATION_MS = 250; +// Drag distance past which release commits (open from DirectSelfRow, +// or close from the panel handle). Mirrors the profile horseshoe's +// 80px so the two gestures feel identical. +const COMMIT_THRESHOLD_PX = 80; +// Fixed sheet height — 2/3 of viewport, what the user signed off on. +// Internal scrolling inside Settings sub-pages handles content +// overflow; no rail re-sizing on menu↔sub-page navigation. +const RAIL_FRACTION = 2 / 3; +// Drag distance over which the radii + void-gap ramp from 0 to their +// full value during finger-drag — same as the profile horseshoe's +// `HORSESHOE_EMERGE_PX`. Matched to `COMMIT_THRESHOLD_PX` so the +// silhouette is fully formed exactly when the gesture qualifies to +// commit. +const HORSESHOE_EMERGE_PX = 80; + +// Symmetric cubic in-out — slow start, fast middle, slow finish. Mirror +// of the profile horseshoe's emerge curve (file rationale there). The +// linear ramp from round 1 came out too "snappy" because the rounding +// jumped in within the first ~10px of drag; the cubic keeps the corners +// barely visible until ~40% of the way through the gesture, then +// blossoms around the midpoint. Used only during finger-drag; release +// transitions use the asymmetric VAUL_EASING curve in CSS. +const easeInOutCubic = (t: number): number => + t < 0.5 ? 4 * t * t * t : 1 - ((-2 * t + 2) ** 3) / 2; + +type DragSource = 'directSelfRow' | 'handle'; + +type DragState = { + source: DragSource; + inputType: 'touch' | 'pointer'; + startY: number; + deltaY: number; +}; + +type MobileSettingsHorseshoeProps = { + children: ReactNode; +}; + +function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps) { + const { t } = useTranslation(); + const sheet = useAtomValue(settingsSheetAtom); + const openSheet = useOpenSettingsSheet(); + const closeSheet = useCloseSettingsSheet(); + + const [drag, setDrag] = useState(null); + const [viewportHeight, setViewportHeight] = useState(() => + typeof window === 'undefined' ? 800 : window.innerHeight + ); + + useEffect(() => { + const onResize = () => setViewportHeight(window.innerHeight); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, []); + + const railHeightPx = Math.round(viewportHeight * RAIL_FRACTION); + const open = !!sheet; + + // Entry-animation gate. On cold-start deep-links (push notification + // → /settings → mobile redirect → /direct/ with atom already set), + // the wrapper would otherwise mount with `expandedPx = railHeightPx` + // and skip the slide-up animation entirely (sheet would snap to + // fully open). The pattern: first render uses `expandedPx = 0` + // (`hasEntered = false`), `useLayoutEffect` queues a + // `requestAnimationFrame` that flips `hasEntered` to true on the + // next frame, the re-render goes to the open state, and the CSS + // `transform` transition animates the difference. Harmless when + // the atom is undefined on mount (just one extra render with the + // same closed state). + // + // `hasEnteredRef` mirrors the state for the unmount-cleanup effect + // below — without it, React 18 strict-mode dev would run the + // cleanup before the rAF flips the state and would clear the atom + // mid-rehearsal, breaking deep-link UX in dev. + const [hasEntered, setHasEntered] = useState(false); + const hasEnteredRef = useRef(false); + useLayoutEffect(() => { + const id = requestAnimationFrame(() => { + hasEnteredRef.current = true; + setHasEntered(true); + }); + return () => cancelAnimationFrame(id); + }, []); + + // Mirror `open` with a delayed unmount so Settings stays in the DOM + // during the 250ms slide-down exit animation. Without this, clearing + // the atom would immediately unmount Settings and the user would see + // an empty panel shrink instead of the menu sliding away with it. + const [keepMounted, setKeepMounted] = useState(open); + useEffect(() => { + if (open) { + setKeepMounted(true); + return undefined; + } + const id = window.setTimeout(() => setKeepMounted(false), ANIMATION_MS); + return () => window.clearTimeout(id); + }, [open]); + + // Persist the last opened sheet state so Settings's `initialPage` + // doesn't reset to undefined during the exit animation. + const lastSheetRef = useRef(sheet); + if (sheet) lastSheetRef.current = sheet; + + const baseExpanded = open && hasEntered ? railHeightPx : 0; + // `directSelfRow` drag: negative deltaY (finger up) opens the sheet. + // `handle` drag: positive deltaY (finger down) closes the sheet. + // Same formula: panel height = base + (-deltaY) = base - deltaY, + // clamped to [0, railHeight]. + const expandedPx = drag + ? Math.max(0, Math.min(railHeightPx, baseExpanded - drag.deltaY)) + : baseExpanded; + const expandedFraction = railHeightPx > 0 ? expandedPx / railHeightPx : 0; + const isDragging = drag !== null; + const horseshoeActive = expandedPx > 0; + + const handleRef = useRef(null); + + // Refs so the always-installed event handlers see the latest state + // without re-subscribing on every render. Same pattern as the + // profile horseshoe. + const dragRef = useRef(null); + dragRef.current = drag; + const sheetRef = useRef(sheet); + sheetRef.current = sheet; + const openSheetRef = useRef(openSheet); + openSheetRef.current = openSheet; + const closeSheetRef = useRef(closeSheet); + closeSheetRef.current = closeSheet; + + // Clear the atom on unmount — tapping a DM row while the sheet is + // open routes to /direct/!roomId, the wrapper unmounts (Direct + // unmounts on route change), and if we don't clear the atom here + // a later return to /direct/ would auto-re-open the sheet (wrong: + // the user moved on). + // + // The `hasEnteredRef` guard skips the dev-mode strict-mode + // rehearsal cleanup (which fires synchronously after the first + // useEffect commit, before the entry-rAF can flip the ref to true). + // Without this, deep-link cold-start in dev would clear the atom + // mid-rehearsal and the sheet would never open. Production runs + // the cleanup only on real unmount, when `hasEnteredRef.current` + // has long since been set. + useEffect( + () => () => { + if (!hasEnteredRef.current) return; + if (sheetRef.current) closeSheetRef.current(); + }, + [] + ); + + // Hardware Escape (web only, rare on mobile) → close. Plain keydown + // listener; no FocusTrap (rationale at the file header). + useEffect(() => { + if (!open) return undefined; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return; + const target = e.target as HTMLElement | null; + if ( + target && + (target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable) + ) { + return; + } + closeSheetRef.current(); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [open]); + + // Drag mechanics — two origin paths: + // + // 1. Document-level touch/pointer on anything matching + // `[data-settings-drag-origin]` (= DirectSelfRow). Listening at + // the document so the row's own onClick handler still fires for + // no-movement taps — touchstart and pointerdown are passive / + // don't preventDefault, so the synthesised click downstream is + // intact. + // 2. Element-level touch/pointer on `handleRef` (the 20px drag bar + // at the top of the open sheet). Only triggers when the sheet + // is open; touches on the panel body itself (Settings content) + // are NOT drag-sensitive so internal scroll works without + // conflict. + useEffect(() => { + const handleEl = handleRef.current; + + // CLAMP, not early-return. Reversal of gesture direction must + // drag `deltaY` back toward 0, not leave the stale value. The + // earlier `if (wrong direction) return` branch let a user swipe + // up 100px, change their mind, swipe back to start, and release + // to find the sheet committed open anyway (stale -100 deltaY + // still cleared the threshold on release). Clamping with + // `Math.min/max(0, rawDelta)` is the same idiom the profile + // horseshoe uses on its open-direction header drag — reversal + // cancels the gesture cleanly. + // + // directSelfRow source: open on drag-up → clamp negative; + // downward (rawDelta > 0) collapses to 0 so the panel returns + // to closed if the user reverses. + // handle source: close on drag-down → clamp positive; + // upward (rawDelta < 0) collapses to 0 so the panel doesn't + // try to expand past its full open height. + // + // preventDefault always (when cancelable) — these touches belong + // to our gesture. DM-list scroll lives ABOVE the row; touches on + // those rows don't hit `[data-settings-drag-origin]` so they + // never enter this handler. + const applyMove = (clientY: number, e: TouchEvent | PointerEvent) => { + const d = dragRef.current; + if (!d) return; + const rawDelta = clientY - d.startY; + const nextDelta = + d.source === 'directSelfRow' ? Math.min(0, rawDelta) : Math.max(0, rawDelta); + if (e.cancelable) e.preventDefault(); + setDrag({ ...d, deltaY: nextDelta }); + }; + + const applyEnd = () => { + const d = dragRef.current; + if (!d) return; + if (d.source === 'directSelfRow' && -d.deltaY > COMMIT_THRESHOLD_PX) { + openSheetRef.current(); + } else if (d.source === 'handle' && d.deltaY > COMMIT_THRESHOLD_PX) { + closeSheetRef.current(); + } + setDrag(null); + }; + + const targetIsDragOrigin = (target: EventTarget | null): boolean => { + if (!target || !(target instanceof Element)) return false; + return target.closest(DRAG_ORIGIN_SELECTOR) !== null; + }; + + // === Touch path === + const onDocTouchStart = (e: TouchEvent) => { + if (dragRef.current) return; + if (sheetRef.current) return; // sheet is open — handle owns drag + if (!targetIsDragOrigin(e.target)) return; + const touch = e.touches[0]; + setDrag({ + source: 'directSelfRow', + inputType: 'touch', + startY: touch.clientY, + deltaY: 0, + }); + }; + const onHandleTouchStart = (e: TouchEvent) => { + if (dragRef.current) return; + if (!sheetRef.current) return; + const touch = e.touches[0]; + setDrag({ source: 'handle', inputType: 'touch', startY: touch.clientY, deltaY: 0 }); + }; + const onTouchMove = (e: TouchEvent) => { + const d = dragRef.current; + if (!d || d.inputType !== 'touch') return; + applyMove(e.touches[0].clientY, e); + }; + const onTouchEnd = () => { + const d = dragRef.current; + if (!d || d.inputType !== 'touch') return; + applyEnd(); + }; + + // === Mouse / pen path === + const onDocPointerDown = (e: PointerEvent) => { + if (e.pointerType === 'touch') return; + if (dragRef.current) return; + if (sheetRef.current) return; + if (e.button !== 0) return; + if (!targetIsDragOrigin(e.target)) return; + setDrag({ + source: 'directSelfRow', + inputType: 'pointer', + startY: e.clientY, + deltaY: 0, + }); + }; + const onHandlePointerDown = (e: PointerEvent) => { + if (e.pointerType === 'touch') return; + if (dragRef.current) return; + if (!sheetRef.current) return; + if (e.button !== 0) return; + setDrag({ source: 'handle', inputType: 'pointer', startY: e.clientY, deltaY: 0 }); + }; + const onDocPointerMove = (e: PointerEvent) => { + if (e.pointerType === 'touch') return; + const d = dragRef.current; + if (!d || d.inputType !== 'pointer') return; + applyMove(e.clientY, e); + }; + const onDocPointerEnd = (e: PointerEvent) => { + if (e.pointerType === 'touch') return; + const d = dragRef.current; + if (!d || d.inputType !== 'pointer') return; + applyEnd(); + }; + + document.addEventListener('touchstart', onDocTouchStart, { passive: true }); + document.addEventListener('touchmove', onTouchMove, { passive: false }); + document.addEventListener('touchend', onTouchEnd, { passive: true }); + document.addEventListener('touchcancel', onTouchEnd, { passive: true }); + document.addEventListener('pointerdown', onDocPointerDown); + document.addEventListener('pointermove', onDocPointerMove, { passive: false }); + document.addEventListener('pointerup', onDocPointerEnd, { passive: true }); + document.addEventListener('pointercancel', onDocPointerEnd, { passive: true }); + if (handleEl) { + handleEl.addEventListener('touchstart', onHandleTouchStart, { passive: true }); + handleEl.addEventListener('pointerdown', onHandlePointerDown); + } + + return () => { + document.removeEventListener('touchstart', onDocTouchStart); + document.removeEventListener('touchmove', onTouchMove); + document.removeEventListener('touchend', onTouchEnd); + document.removeEventListener('touchcancel', onTouchEnd); + document.removeEventListener('pointerdown', onDocPointerDown); + document.removeEventListener('pointermove', onDocPointerMove); + document.removeEventListener('pointerup', onDocPointerEnd); + document.removeEventListener('pointercancel', onDocPointerEnd); + if (handleEl) { + handleEl.removeEventListener('touchstart', onHandleTouchStart); + handleEl.removeEventListener('pointerdown', onHandlePointerDown); + } + }; + }, []); + + // Geometry — radii and void gap ramp through `easeInOutCubic` (slow + // start → fast middle → slow finish) during finger-drag, matching + // the profile horseshoe's emerge curve. Same `HORSESHOE_EMERGE_PX` + // window as the profile horseshoe so the rounding finishes exactly + // when the gesture qualifies to commit. During release (not + // dragging), the values jump to their full target and the CSS + // transition (VAUL_EASING) carries them visually. + // + // The mask: appBody stays in place, its bottom edge is "carved + // away" by `clip-path: inset(0 0 BOTTOMpx 0 round 0 0 Rpx Rpx)` + // where BOTTOM = expandedPx + voidGap. The carved zone exposes the + // container's bg (the void colour) above the silhouette; the void + // gap is just `voidGap` pixels of that exposed bg between the + // silhouette's top edge and the clip-path's lower edge. + let horseshoeRamp: number; + if (isDragging) { + horseshoeRamp = easeInOutCubic(Math.min(1, expandedPx / HORSESHOE_EMERGE_PX)); + } else { + horseshoeRamp = expandedFraction > 0 ? 1 : 0; + } + const silhouetteRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX; + const appBodyRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX; + const appBodyGapPx = horseshoeRamp * css.HORSESHOE_GAP_PX; + const appBodyMaskBottomPx = expandedPx + appBodyGapPx; + + // `inset()` shorthand: top right bottom left, then `round` followed + // by 4 corner radii in TL TR BR BL order. Only the bottom two carry + // the radius so the visible top portion of appBody has rounded BL/BR + // at the clip boundary. Always emitted (even when all values are 0 + // for the closed state) so CSS can transition smoothly between + // closed and open — interpolating between `inset(...)` and an + // `undefined` clip-path would snap rather than animate. + const appBodyClipPath = `inset(0px 0px ${appBodyMaskBottomPx}px 0px round 0px 0px ${appBodyRadiusPx}px ${appBodyRadiusPx}px)`; + + const silhouetteTransition = isDragging + ? 'none' + : `height ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`; + const appBodyTransition = isDragging ? 'none' : `clip-path ${ANIMATION_MS}ms ${VAUL_EASING}`; + + const containerStyle: React.CSSProperties = { + backgroundColor: horseshoeActive ? VOJO_HORSESHOE_VOID_COLOR : undefined, + }; + + const settingsState = sheet ?? lastSheetRef.current; + const renderSettings = keepMounted || isDragging; + + // Render an invisible marker into `#portalContainer` while the sheet + // is open. The global Android-back handler in `useAndroidBackButton` + // checks for `portalContainer.firstChild` and, if present, dispatches + // an `Escape` keydown that our `keydown` effect above catches to + // close the sheet (or, when handled in capture by Settings, to pop + // the active sub-page back to the menu). Without this marker the + // global handler would fall through to `navigate(-1)` and bounce the + // user out of /direct/ entirely. Portal target falls back to body + // when running in tests / SSR where `#portalContainer` isn't + // mounted yet. + const portalTarget = + typeof document !== 'undefined' + ? document.getElementById('portalContainer') ?? document.body + : null; + + return ( +
+ {open && portalTarget + ? createPortal( +