refactor(profile): rebuild mobile horseshoe as single silhouette wrapper with Vaul ease curves replacing the two-radius emerge handoff

This commit is contained in:
heaven 2026-05-11 02:00:04 +03:00
parent 626a7c2d1d
commit 117bb9fba4
2 changed files with 280 additions and 195 deletions

View file

@ -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',
});

View file

@ -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 (
<div className={css.container} style={containerStyle}>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
// Mobile: only swipe-up dismisses the rail. Click on chat /
// scroll on chat must NOT close it AND must remain
// interactive.
clickOutsideDeactivates: false,
allowOutsideClick: () => true,
escapeDeactivates: stopPropagation,
onDeactivate: () => {
if (profileStateRef.current) close();
},
checkCanFocusTrap: () => Promise.resolve(),
<div
className={css.silhouette}
style={{
borderBottomLeftRadius: `${silhouetteRadiusPx}px`,
borderBottomRightRadius: `${silhouetteRadiusPx}px`,
transition: silhouetteTransition,
}}
// 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}
>
<div
ref={panelRef}
className={css.panel}
style={{
height: `${railHeightPx}px`,
transform: `translateY(${expandedPx - railHeightPx}px)`,
borderBottomLeftRadius: panelBottomRadius
? `${panelBottomRadius}px`
: 0,
borderBottomRightRadius: panelBottomRadius
? `${panelBottomRadius}px`
: 0,
transition: panelTransition,
visibility: expandedPx > 0 ? 'visible' : 'hidden',
<FocusTrap
focusTrapOptions={{
initialFocus: false,
// Mobile: only swipe-up dismisses the rail. Click on chat /
// scroll on chat must NOT close it AND must remain
// interactive.
clickOutsideDeactivates: false,
allowOutsideClick: () => 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 ? (
<button
type="button"
className={css.avatarFullView}
onClick={() => setAvatarMode(false)}
aria-label={t('Room.collapse_avatar')}
<div
ref={panelRef}
className={css.panelViewport}
style={{
height: `${expandedPx}px`,
transition: panelViewportTransition,
visibility: expandedPx > 0 ? 'visible' : 'hidden',
}}
>
<div
className={css.panelContent}
style={{ height: `${railHeightPx}px` }}
>
{renderUserAvatarUrl ? (
<img
className={css.avatarFullImage}
src={renderUserAvatarUrl}
alt={renderUserId ?? ''}
draggable={false}
/>
) : (
<div
className={css.avatarFullFallback}
style={{ backgroundColor: colorMXID(renderUserId ?? '') }}
{avatarMode ? (
<button
type="button"
className={css.avatarFullView}
onClick={() => setAvatarMode(false)}
aria-label={t('Room.collapse_avatar')}
>
{(renderUserId && getMxIdLocalPart(renderUserId)?.[0]) ?? '?'}
{renderUserAvatarUrl ? (
<img
className={css.avatarFullImage}
src={renderUserAvatarUrl}
alt={renderUserId ?? ''}
draggable={false}
/>
) : (
<div
className={css.avatarFullFallback}
style={{ backgroundColor: colorMXID(renderUserId ?? '') }}
>
{(renderUserId && getMxIdLocalPart(renderUserId)?.[0]) ?? '?'}
</div>
)}
</button>
) : (
<div className={css.panelInner}>
{/* 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. */}
<div className={css.panelScroll}>
<div style={{ padding: config.space.S400 }}>
{renderUserId && (
<UserRoomProfile
userId={renderUserId}
onAvatarClick={() => setAvatarMode(true)}
/>
)}
</div>
</div>
<div className={css.panelHandle} aria-label={t('Room.drag_to_close')}>
<div className={css.panelHandleBar} />
</div>
</div>
)}
</button>
) : (
<div className={css.panelInner}>
{/* 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. */}
<div className={css.panelScroll}>
<div style={{ padding: config.space.S400 }}>
{renderUserId && (
<UserRoomProfile
userId={renderUserId}
onAvatarClick={() => setAvatarMode(true)}
/>
)}
</div>
</div>
<div className={css.panelHandle} aria-label={t('Room.drag_to_close')}>
<div className={css.panelHandleBar} />
</div>
</div>
)}
</div>
</FocusTrap>
</div>
</FocusTrap>
<div
className={css.chatColumn}
style={{
// Push the chat column down by `panel + gap` so its rounded
// top edge sits right below the panel's rounded bottom.
// The panel is `position: absolute`, so without this margin
// the chat would sit behind the panel and the top radius
// would be invisible.
marginTop:
expandedPx > 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',
}}
>
<div
ref={headerRef}
className={css.headerWrap}
className={css.headerViewport}
style={{
// Collapse the chat header to 0 height while the rail is
// open so the only visible chrome between the rail's
// rounded bottom and the chat's rounded top is the
// HORSESHOE_GAP_PX gap. CSS-grid's `1fr → 0fr` is animatable, unlike
// `height: auto`, and avoids needing to measure the
// header's intrinsic height in JS.
// 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`,
transition: isDragging ? 'none' : `grid-template-rows ${ANIMATION_MS}ms ease`,
transition: headerViewportTransition,
// While the rail is mostly open the (collapsed) header
// shouldn't intercept drag-back-to-open gestures —
// `pan-x` lets horizontal Android-back-edge gestures
@ -384,8 +418,27 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
touchAction: headerDragEnabled ? 'pan-x' : undefined,
}}
>
<div className={css.headerWrapInner}>{header}</div>
<div className={css.headerViewportInner}>{header}</div>
</div>
</div>
<div
className={css.chatBody}
style={{
marginTop: `${chatGapPx}px`,
borderTopLeftRadius: chatRadiusPx ? `${chatRadiusPx}px` : undefined,
borderTopRightRadius: chatRadiusPx ? `${chatRadiusPx}px` : undefined,
// Clip chat content to the rounded top corners. Folds
// popouts use Portals → escape this clip; the timeline
// keeps its own scroll container.
overflow: chatRadiusPx > 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}
</div>
</div>