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_RADIUS_PX = 32;
export const HORSESHOE_GAP_PX = 12; export const HORSESHOE_GAP_PX = 12;
// Outer container — establishes the positioning context for the // Outer container — flex column hosting the silhouette (panel +
// absolutely-positioned panel and the under-panel chat column. // header live inside) and the chat body below. `overflow: hidden`
// `overflow: hidden` clips the panel when it slides above the // clips the chat body's rounded top corners against the container's
// visible area (translateY(-railHeight)) so it disappears cleanly, // own edges; `background` paints the dark «void» behind the gap
// and clips the chat's animated rounded top corners against the // between silhouette and chat body when the drawer is open.
// container's own edges.
export const container = style({ export const container = style({
position: 'relative', position: 'relative',
display: 'flex', display: 'flex',
@ -22,31 +21,57 @@ export const container = style({
overflow: 'hidden', 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 // `overflow: hidden` is what makes the radius actually crop content
// bottom call rail's rounded top corners. `position: absolute` lets // (without it the rounded bottom would just be an SVG-like outline
// it slide in from above (translateY) without disturbing the chat // behind opaque children). The flex column stacks panelViewport (top)
// column's flex layout below. // over headerViewport (bottom), so the silhouette's bottom edge
export const panel = style({ // always coincides with whichever child is currently last-visible —
position: 'absolute', // which is exactly the "bottom edge of the silhouette" the rounding
top: 0, // sits on.
left: 0, export const silhouette = style({
zIndex: 2, display: 'flex',
width: '100%', flexDirection: 'column',
// The bottom-corner radius is interpolated inline so the panel flexShrink: 0,
// 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', overflow: 'hidden',
backgroundColor: color.Background.Container, backgroundColor: color.Background.Container,
willChange: 'transform', willChange: 'border-bottom-left-radius, border-bottom-right-radius',
// Allow vertical pan inside the panel (Scroll content); the });
// explicit drag-up handler will preventDefault on close gestures.
// === 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', touchAction: 'pan-y',
}); });
export const panelContent = style({
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
});
export const panelInner = style({ export const panelInner = style({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@ -54,11 +79,11 @@ export const panelInner = style({
}); });
// Functional overflow without a visible scrollbar. The card's // Functional overflow without a visible scrollbar. The card's
// content (hero + ~4 info rows + chip row) almost always fits // content (hero + ~4 info rows + chip row) almost always fits inside
// inside the rail height, but moderation alerts can push it past // the rail height, but moderation alerts can push it past — we keep
// — we keep the panel scrollable for that case while suppressing // the panel scrollable for that case while suppressing the
// the scrollbar chrome (it's not a useful affordance on a 42vh // scrollbar chrome (it's not a useful affordance on a 42vh rail and
// rail and the user explicitly asked us to drop it). // the user explicitly asked us to drop it).
export const panelScroll = style({ export const panelScroll = style({
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
@ -95,9 +120,9 @@ export const panelHandleBar = style({
backgroundColor: color.Surface.ContainerLine, backgroundColor: color.Surface.ContainerLine,
}); });
// Avatar full-view mode — fills the panel with the user's avatar at // Avatar full-view mode — fills the panel content area with the
// full size when the user taps the avatar inside the open panel. // user's avatar at full size when the user taps the avatar inside
// Click-to-revert switches back to the regular profile content. // the open panel. Click reverts to the regular profile content.
export const avatarFullView = style({ export const avatarFullView = style({
width: '100%', width: '100%',
height: '100%', height: '100%',
@ -106,6 +131,9 @@ export const avatarFullView = style({
justifyContent: 'center', justifyContent: 'center',
cursor: 'pointer', cursor: 'pointer',
position: 'relative', position: 'relative',
background: 'transparent',
border: 'none',
padding: 0,
}); });
export const avatarFullImage = style({ export const avatarFullImage = style({
@ -126,30 +154,34 @@ export const avatarFullFallback = style({
textTransform: 'uppercase', textTransform: 'uppercase',
}); });
// === Under-panel chat column === // === Header viewport === Holds the chat header as the silhouette's
// // bottom child. Same CSS-grid `1fr → 0fr` row-track trick the legacy
// `margin-top` is interpolated inline (panel height + 8px gap) so its // `headerWrap` used so the row animates smoothly without measuring
// rounded top edge sits right under the panel's rounded bottom. The // the header's intrinsic height in JS. The inner div clips the
// rounded corners themselves are inline-styled on this same element. // header content as the track collapses.
export const chatColumn = style({ export const headerViewport = 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({
display: 'grid', display: 'grid',
flexShrink: 0, flexShrink: 0,
willChange: 'grid-template-rows', willChange: 'grid-template-rows',
}); });
export const headerWrapInner = style({ export const headerViewportInner = style({
minHeight: 0, minHeight: 0,
overflow: 'hidden', 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 // Wrapper around the room's chat column that adds the user-profile
// surface on top. // surface on top.
// //
// Mobile (≤ 750): the top «horseshoe» rail. The chat header collapses // Mobile (≤ 750): the top «horseshoe» rail. Panel and chat header
// to height 0 while the rail is open so the visible gap between the // live as siblings inside a single `silhouette` wrapper whose own
// rail's rounded bottom and the chat's rounded top is exactly the // `overflow:hidden + border-bottom-radius` cuts the bottom of the
// shared `HORSESHOE_GAP_PX`, matching the bottom call horseshoe. // 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 // Tablet + Desktop (> 750): no top horseshoe. The wrapper is a thin
// passthrough; the profile renders as a right-side pane via // 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). // Past this many pixels of drag the gesture commits (open or close).
const COMMIT_THRESHOLD_PX = 80; const COMMIT_THRESHOLD_PX = 80;
const ANIMATION_MS = 250; 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 = { type RoomViewProfilePanelProps = {
header: ReactNode; header: ReactNode;
@ -52,9 +92,10 @@ type DragState = {
// Mobile-only top horseshoe. Slides down from above on drag-down on // 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 // 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 // anywhere on the panel. Panel and chat header share a single
// collapses to height 0 so there's no transparent ~50px void below // `silhouette` wrapper whose `overflow:hidden + border-bottom-radius`
// the rail — the perceived gap matches the call horseshoe's gap. // owns the visible rounded bottom — see file header for the
// architectural rationale.
function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps) { function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const profileState = useAtomValue(userRoomProfileAtom); const profileState = useAtomValue(userRoomProfileAtom);
@ -106,6 +147,7 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
: baseExpanded; : baseExpanded;
const expandedFraction = railHeightPx > 0 ? expandedPx / railHeightPx : 0; const expandedFraction = railHeightPx > 0 ? expandedPx / railHeightPx : 0;
const isDragging = drag !== null; const isDragging = drag !== null;
const horseshoeActive = expandedPx > 0;
const liveUserId = const liveUserId =
profileState?.userId ?? (drag?.source === 'header' ? headerDragPeer : undefined); profileState?.userId ?? (drag?.source === 'header' ? headerDragPeer : undefined);
@ -220,42 +262,57 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
}; };
}, [headerDragEnabled, headerDragPeer]); }, [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' ? 'none'
: `transform ${ANIMATION_MS}ms ease, border-bottom-left-radius ${ANIMATION_MS}ms ease, border-bottom-right-radius ${ANIMATION_MS}ms ease`; : `height ${ANIMATION_MS}ms ${VAUL_EASING}`;
const chatColumnTransition = isDragging const headerViewportTransition = isDragging
? 'none' ? '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 = { 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 ( return (
<div className={css.container} style={containerStyle}> <div className={css.container} style={containerStyle}>
<div
className={css.silhouette}
style={{
borderBottomLeftRadius: `${silhouetteRadiusPx}px`,
borderBottomRightRadius: `${silhouetteRadiusPx}px`,
transition: silhouetteTransition,
}}
>
<FocusTrap <FocusTrap
focusTrapOptions={{ focusTrapOptions={{
initialFocus: false, initialFocus: false,
@ -278,19 +335,16 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
> >
<div <div
ref={panelRef} ref={panelRef}
className={css.panel} className={css.panelViewport}
style={{ style={{
height: `${railHeightPx}px`, height: `${expandedPx}px`,
transform: `translateY(${expandedPx - railHeightPx}px)`, transition: panelViewportTransition,
borderBottomLeftRadius: panelBottomRadius
? `${panelBottomRadius}px`
: 0,
borderBottomRightRadius: panelBottomRadius
? `${panelBottomRadius}px`
: 0,
transition: panelTransition,
visibility: expandedPx > 0 ? 'visible' : 'hidden', visibility: expandedPx > 0 ? 'visible' : 'hidden',
}} }}
>
<div
className={css.panelContent}
style={{ height: `${railHeightPx}px` }}
> >
{avatarMode ? ( {avatarMode ? (
<button <button
@ -318,12 +372,12 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
) : ( ) : (
<div className={css.panelInner}> <div className={css.panelInner}>
{/* No visible scrollbar the card content (hero + {/* No visible scrollbar the card content (hero +
~4 info rows + a chip row) almost always fits, and ~4 info rows + a chip row) almost always fits,
on the rare overflow case (lots of moderation and on the rare overflow case (lots of
alerts) we keep functional scrolling via the moderation alerts) we keep functional scrolling
`panelScroll` class which sets `overflow: auto` + via the `panelScroll` class which sets
`scrollbar-width: none` / `::-webkit-scrollbar` `overflow: auto` + `scrollbar-width: none` /
hidden. */} `::-webkit-scrollbar` hidden. */}
<div className={css.panelScroll}> <div className={css.panelScroll}>
<div style={{ padding: config.space.S400 }}> <div style={{ padding: config.space.S400 }}>
{renderUserId && ( {renderUserId && (
@ -340,43 +394,23 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
</div> </div>
)} )}
</div> </div>
</div>
</FocusTrap> </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 <div
ref={headerRef} ref={headerRef}
className={css.headerWrap} className={css.headerViewport}
style={{ style={{
// Collapse the chat header to 0 height while the rail is // Collapse the chat header to 0 height as the panel
// open so the only visible chrome between the rail's // expands — same `1fr → 0fr` row-track trick the legacy
// rounded bottom and the chat's rounded top is the // layout used. CSS-grid track interpolation is animatable
// HORSESHOE_GAP_PX gap. CSS-grid's `1fr → 0fr` is animatable, unlike // (unlike `height: auto`) and avoids measuring the
// `height: auto`, and avoids needing to measure the // header's intrinsic height in JS. As the row collapses,
// header's intrinsic height in JS. // 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`, 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 // While the rail is mostly open the (collapsed) header
// shouldn't intercept drag-back-to-open gestures — // shouldn't intercept drag-back-to-open gestures —
// `pan-x` lets horizontal Android-back-edge gestures // `pan-x` lets horizontal Android-back-edge gestures
@ -384,8 +418,27 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
touchAction: headerDragEnabled ? 'pan-x' : undefined, touchAction: headerDragEnabled ? 'pan-x' : undefined,
}} }}
> >
<div className={css.headerWrapInner}>{header}</div> <div className={css.headerViewportInner}>{header}</div>
</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} {children}
</div> </div>
</div> </div>