From 50c58a172605e9f2772ec25a39bc12b3bfb5f6eb Mon Sep 17 00:00:00 2001 From: "v.lagerev" Date: Mon, 11 May 2026 02:00:04 +0300 Subject: [PATCH] refactor(profile): rebuild mobile horseshoe as single silhouette wrapper with Vaul ease curves replacing the two-radius emerge handoff --- .../features/room/RoomViewProfilePanel.css.ts | 136 ++++--- .../features/room/RoomViewProfilePanel.tsx | 339 ++++++++++-------- 2 files changed, 280 insertions(+), 195 deletions(-) diff --git a/src/app/features/room/RoomViewProfilePanel.css.ts b/src/app/features/room/RoomViewProfilePanel.css.ts index 92354afd..e1741977 100644 --- a/src/app/features/room/RoomViewProfilePanel.css.ts +++ b/src/app/features/room/RoomViewProfilePanel.css.ts @@ -6,12 +6,11 @@ import { color, toRem } from 'folds'; export const HORSESHOE_RADIUS_PX = 32; export const HORSESHOE_GAP_PX = 12; -// Outer container — establishes the positioning context for the -// absolutely-positioned panel and the under-panel chat column. -// `overflow: hidden` clips the panel when it slides above the -// visible area (translateY(-railHeight)) so it disappears cleanly, -// and clips the chat's animated rounded top corners against the -// container's own edges. +// 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. export const container = style({ position: 'relative', display: 'flex', @@ -22,31 +21,57 @@ export const container = style({ overflow: 'hidden', }); -// === Panel === Mobile-only top horseshoe. +// === Silhouette === Single-element wrapper that owns the +// rounded-bottom geometry shared by the user-card panel and the chat +// header. Both live as direct children of this one box, so a single +// `border-bottom-{left,right}-radius` cuts the bottom of the +// combined block — there's no «hand-off» from header-bottom-radius +// to panel-bottom-radius mid-drag because there's only one radius +// to begin with. Replaces the legacy `horseshoeEmerge` curve that +// ramped two separate radii in the last 15% of drag. // -// Full-width sheet with both bottom corners rounded, mirroring the -// bottom call rail's rounded top corners. `position: absolute` lets -// it slide in from above (translateY) without disturbing the chat -// column's flex layout below. -export const panel = style({ - position: 'absolute', - top: 0, - left: 0, - zIndex: 2, - width: '100%', - // The bottom-corner radius is interpolated inline so the panel - // bottom stays flush against the chat-column top during drag (both - // square-cornered, zero gap), and only blossoms into the rounded - // horseshoe shape once the gesture commits and CSS transitions - // kick in alongside the gap. +// `overflow: hidden` is what makes the radius actually crop content +// (without it the rounded bottom would just be an SVG-like outline +// behind opaque children). The flex column stacks panelViewport (top) +// over headerViewport (bottom), so the silhouette's bottom edge +// always coincides with whichever child is currently last-visible — +// which is exactly the "bottom edge of the silhouette" the rounding +// sits on. +export const silhouette = style({ + display: 'flex', + flexDirection: 'column', + flexShrink: 0, overflow: 'hidden', backgroundColor: color.Background.Container, - willChange: 'transform', - // Allow vertical pan inside the panel (Scroll content); the - // explicit drag-up handler will preventDefault on close gestures. + willChange: 'border-bottom-left-radius, border-bottom-right-radius', +}); + +// === Panel viewport === Variable-height clipping window for the +// fixed-height panel content (railHeight tall, anchored bottom:0). +// As the viewport grows from 0 to railHeight during drag, more of +// panelContent is revealed from its bottom edge upward +// (handle → scroll content → avatar top) — equivalent visual to the +// legacy `transform: translateY(expandedPx - railHeight)` trick that +// shifted the whole panel down from above viewport. +// +// `touchAction: pan-y` keeps the panel-internal scroll responsive; +// the drag-up close gesture preventDefault's explicitly inside the +// touchmove handler. +export const panelViewport = style({ + position: 'relative', + width: '100%', + overflow: 'hidden', + willChange: 'height', touchAction: 'pan-y', }); +export const panelContent = style({ + position: 'absolute', + bottom: 0, + left: 0, + right: 0, +}); + export const panelInner = style({ display: 'flex', flexDirection: 'column', @@ -54,11 +79,11 @@ export const panelInner = style({ }); // 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). +// 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). export const panelScroll = style({ flex: 1, minHeight: 0, @@ -95,9 +120,9 @@ export const panelHandleBar = style({ backgroundColor: color.Surface.ContainerLine, }); -// Avatar full-view mode — fills the panel with the user's avatar at -// full size when the user taps the avatar inside the open panel. -// Click-to-revert switches back to the regular profile content. +// Avatar full-view mode — fills the panel content area with the +// user's avatar at full size when the user taps the avatar inside +// the open panel. Click reverts to the regular profile content. export const avatarFullView = style({ width: '100%', height: '100%', @@ -106,6 +131,9 @@ export const avatarFullView = style({ justifyContent: 'center', cursor: 'pointer', position: 'relative', + background: 'transparent', + border: 'none', + padding: 0, }); export const avatarFullImage = style({ @@ -126,30 +154,34 @@ export const avatarFullFallback = style({ textTransform: 'uppercase', }); -// === Under-panel chat column === -// -// `margin-top` is interpolated inline (panel height + 8px gap) so its -// rounded top edge sits right under the panel's rounded bottom. The -// rounded corners themselves are inline-styled on this same element. -export const chatColumn = style({ - display: 'flex', - flex: 1, - flexDirection: 'column', - minWidth: 0, - minHeight: 0, - willChange: 'margin-top', -}); - -// CSS-grid `1fr → 0fr` is animatable, unlike `height: auto`. The -// outer wrap holds the row track; the inner div is the actual -// scroll/clip surface. -export const headerWrap = 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. +export const headerViewport = style({ display: 'grid', flexShrink: 0, willChange: 'grid-template-rows', }); -export const headerWrapInner = style({ +export const headerViewportInner = style({ minHeight: 0, overflow: 'hidden', }); + +// === Chat body === Timeline + input below the silhouette. The +// rounded top corners and the `margin-top` void gap appear together +// with the silhouette's rounded bottom — all three are driven by the +// same `horseshoeRamp` (live formula while finger-dragging, binary +// with CSS transition during release animation), so the two horseshoes +// and the void emerge in lockstep at drag-start without any per-element +// «emerge curve» of their own. +export const chatBody = style({ + display: 'flex', + flex: 1, + flexDirection: 'column', + minWidth: 0, + minHeight: 0, + willChange: 'margin-top, border-top-left-radius, border-top-right-radius', +}); diff --git a/src/app/features/room/RoomViewProfilePanel.tsx b/src/app/features/room/RoomViewProfilePanel.tsx index df891417..56236769 100644 --- a/src/app/features/room/RoomViewProfilePanel.tsx +++ b/src/app/features/room/RoomViewProfilePanel.tsx @@ -1,10 +1,17 @@ // Wrapper around the room's chat column that adds the user-profile // surface on top. // -// Mobile (≤ 750): the top «horseshoe» rail. The chat header collapses -// to height 0 while the rail is open so the visible gap between the -// rail's rounded bottom and the chat's rounded top is exactly the -// shared `HORSESHOE_GAP_PX`, matching the bottom call horseshoe. +// Mobile (≤ 750): the top «horseshoe» rail. Panel and chat header +// live as siblings inside a single `silhouette` wrapper whose own +// `overflow:hidden + border-bottom-radius` cuts the bottom of the +// combined block. There's no separate header-radius / panel-radius +// hand-off mid-drag — the rounding is a property of the silhouette +// itself, so it stays visually anchored to «the bottom of whatever's +// currently last-visible» throughout the gesture without seams or +// blips. Replaces the legacy two-element layout where panel was +// `position: absolute` over a chat column that owned the header in +// flow; each had its own radius animated by the now-deleted +// `horseshoeEmerge` curve. // // Tablet + Desktop (> 750): no top horseshoe. The wrapper is a thin // passthrough; the profile renders as a right-side pane via @@ -38,6 +45,39 @@ const RAIL_HEIGHT_FRACTION = 0.42; // Past this many pixels of drag the gesture commits (open or close). const COMMIT_THRESHOLD_PX = 80; const ANIMATION_MS = 250; +// Drag distance over which the horseshoe-radius + chat-gap + +// chat-radius ramp from 0 to their full value during finger-drag. +// Matched to COMMIT_THRESHOLD_PX so the silhouette is fully formed +// exactly when the gesture qualifies to commit — the user sees the +// rounding "earn" itself across the whole gesture instead of +// snapping in immediately. Combined with the easeInOutCubic curve +// below, gives a gentle start (corners barely emerge in first +// ~10px) → fast middle (the bulk of the round-in happens around +// the visual halfway point) → soft settle near commit. +const HORSESHOE_EMERGE_PX = 80; + +// Vaul / iOS system curve (asymmetric ease-out — steep accelerate, +// long decelerate). Reads as "spring-like" without the overshoot of +// an actual spring, which is what makes Apple sheet transitions feel +// non-mechanical. Used on the CSS release transitions only — the +// release phase wants instant feedback + soft settle. Vaul itself +// also reserves this curve for release and tracks the finger linearly +// during drag; we intentionally diverge from that during drag (see +// `easeInOutCubic` below) because the user explicitly asked for a +// gentle-start ramp on the rounding, not direct-manipulation. +const VAUL_EASING = 'cubic-bezier(0.32, 0.72, 0, 1)'; + +// Symmetric cubic in-out S-curve — slow start, fast middle, slow +// finish. NOT a Vaul-curve approximation: Vaul's curve is asymmetric +// (steep accelerate, long decelerate) so it grows fast from the +// first pixel of drag, which would defeat the explicit user ask of +// «не сразу скругление». Symmetric in-out gives the rounding a +// barely-visible start that only blossoms around the halfway point, +// which is the felt-feel the user signed off on. Release animations +// keep the asymmetric Vaul curve in CSS where direct manipulation +// is over and the auto-animation can take centre stage. +const easeInOutCubic = (t: number): number => + t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; type RoomViewProfilePanelProps = { header: ReactNode; @@ -52,9 +92,10 @@ type DragState = { // Mobile-only top horseshoe. Slides down from above on drag-down on // the chat header (1:1 DM only); slides up to close on drag-up -// anywhere on the panel. While the rail is open the chat header -// collapses to height 0 so there's no transparent ~50px void below -// the rail — the perceived gap matches the call horseshoe's gap. +// anywhere on the panel. Panel and chat header share a single +// `silhouette` wrapper whose `overflow:hidden + border-bottom-radius` +// owns the visible rounded bottom — see file header for the +// architectural rationale. function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps) { const { t } = useTranslation(); const profileState = useAtomValue(userRoomProfileAtom); @@ -106,6 +147,7 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps) : baseExpanded; const expandedFraction = railHeightPx > 0 ? expandedPx / railHeightPx : 0; const isDragging = drag !== null; + const horseshoeActive = expandedPx > 0; const liveUserId = profileState?.userId ?? (drag?.source === 'header' ? headerDragPeer : undefined); @@ -220,163 +262,155 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps) }; }, [headerDragEnabled, headerDragPeer]); - const panelTransition = isDragging + // Horseshoe geometry — single ramp factor drives the silhouette + // bottom radius, the chat top radius, AND the chat margin-top gap. + // + // • Finger-drag: live formula over the first HORSESHOE_EMERGE_PX + // (80px = COMMIT_THRESHOLD_PX) of drag, so the rounding and + // gap visibly emerge across the whole commit gesture and finish + // exactly when the gesture qualifies to commit. + // • Release animation: binary on/off, paired with a CSS + // transition so the values decay smoothly in lockstep with the + // 250ms panel-height / header-collapse animations. + // + // The same ramp factor feeds all three properties so they always + // appear and disappear together — no per-element timing skew that + // could create a perceived «blip». + const horseshoeRamp = isDragging + ? easeInOutCubic(Math.min(1, expandedPx / HORSESHOE_EMERGE_PX)) + : horseshoeActive + ? 1 + : 0; + + const silhouetteRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX; + const chatRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX; + const chatGapPx = horseshoeRamp * css.HORSESHOE_GAP_PX; + + const panelViewportTransition = isDragging ? 'none' - : `transform ${ANIMATION_MS}ms ease, border-bottom-left-radius ${ANIMATION_MS}ms ease, border-bottom-right-radius ${ANIMATION_MS}ms ease`; - const chatColumnTransition = isDragging + : `height ${ANIMATION_MS}ms ${VAUL_EASING}`; + const headerViewportTransition = isDragging ? 'none' - : `margin-top ${ANIMATION_MS}ms ease, border-top-left-radius ${ANIMATION_MS}ms ease, border-top-right-radius ${ANIMATION_MS}ms ease`; + : `grid-template-rows ${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}`; + const chatBodyTransition = isDragging + ? 'none' + : `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: expandedPx > 0 ? '#090909' : undefined, + backgroundColor: horseshoeActive ? '#090909' : undefined, }; - // The «two horseshoes» visual (HORSESHOE_GAP_PX gap + - // HORSESHOE_RADIUS_PX bottom/top radii) is gated by an emerge - // curve that stays at 0 for most of the - // open progress and only ramps to 1 in the last 15%. Two reasons: - // - // 1) Drag-down to open — the chat header is still visible under - // the descending rail. A linear gap would show a black `#090909` - // strip between rail and header («экстра геп между старой - // маленьким хедером чата и карточкой-подковой»). With the - // curve the rail sits flush against the (collapsing) header - // until the very end, and the gap blossoms when the header - // has fully retracted. - // - // 2) Drag-up to close — touchstart begins at expandedFraction=1, - // so the gap is already 8px. As expandedFraction dips even - // slightly the curve drops sharply, so the gap closes - // smoothly with the user's gesture instead of jolting to 0 - // at touchstart. - const horseshoeEmerge = Math.max(0, (expandedFraction - 0.85) / 0.15); - const chatRadius = horseshoeEmerge * css.HORSESHOE_RADIUS_PX; - const chatGap = horseshoeEmerge * css.HORSESHOE_GAP_PX; - const panelBottomRadius = chatRadius; - return (
- true, - escapeDeactivates: stopPropagation, - onDeactivate: () => { - if (profileStateRef.current) close(); - }, - checkCanFocusTrap: () => Promise.resolve(), +
-
0 ? 'visible' : 'hidden', + true, + escapeDeactivates: stopPropagation, + onDeactivate: () => { + if (profileStateRef.current) close(); + }, + checkCanFocusTrap: () => Promise.resolve(), }} + // Don't toggle `active` on drag — flipping it to false makes + // focus-trap-react fire `onDeactivate` synthetically, so a + // plain panel tap (deltaY=0) closes the rail. Trap stays + // active for the entire open lifetime. + active={open} > - {avatarMode ? ( - + ) : ( +
+ {/* 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. */} +
+
+ {renderUserId && ( + setAvatarMode(true)} + /> + )} +
+
+
+
+
)} - - ) : ( -
- {/* 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. */} -
-
- {renderUserId && ( - setAvatarMode(true)} - /> - )} -
-
-
-
-
- )} -
- +
+
-
0 ? `${expandedPx + chatGap}px` : 0, - borderTopLeftRadius: chatRadius ? `${chatRadius}px` : undefined, - borderTopRightRadius: chatRadius ? `${chatRadius}px` : undefined, - // Clip the chat content to the rounded top corners. Folds - // popouts use Portals → escape this clip; the timeline - // keeps its own scroll container. - overflow: chatRadius > 0 ? 'hidden' : undefined, - transition: chatColumnTransition, - // Suppress browser pull-to-refresh / scroll-chaining so a - // header-drag-down doesn't fire pull-to-refresh briefly - // before the touchmove handler intercepts. - overscrollBehaviorY: 'contain', - }} - >
-
{header}
+
{header}
+
+ +
0 ? 'hidden' : undefined, + transition: chatBodyTransition, + // Suppress browser pull-to-refresh / scroll-chaining so a + // header-drag-down doesn't fire pull-to-refresh briefly + // before the touchmove handler intercepts. + overscrollBehaviorY: 'contain', + }} + > {children}