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 (