vojo/src/app/features/room/RoomViewProfilePanel.css.ts

236 lines
8.8 KiB
TypeScript

import { style } from '@vanilla-extract/css';
import { color, toRem } from 'folds';
// Shared with the call-horseshoe surface so both ends of the chat
// (top profile rail, bottom call rail) read with identical geometry.
export const HORSESHOE_RADIUS_PX = 32;
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 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',
flex: 1,
flexDirection: 'column',
minWidth: 0,
minHeight: 0,
overflow: 'hidden',
});
// === 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.
//
// `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: '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. `userSelect: none` keeps mouse drag clean — on
// the pointer path a left-button drag would otherwise race the
// browser's native text-selection drag (selection highlight grows
// during the gesture and the user has to click once to deselect
// after release).
export const panelViewport = style({
position: 'relative',
width: '100%',
overflow: 'hidden',
willChange: 'height',
touchAction: 'pan-y',
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',
top: 0,
left: 0,
right: 0,
paddingTop: 'var(--vojo-safe-top, 0px)',
});
export const panelInner = style({
display: 'flex',
flexDirection: 'column',
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).
export const panelScroll = style({
flex: 1,
minHeight: 0,
overflow: 'auto',
scrollbarWidth: 'none',
selectors: {
'&::-webkit-scrollbar': {
display: 'none',
},
},
});
// Bottom drag handle — visible cue that the panel is draggable. The
// whole panel surface is also drag-sensitive (see TSX), but the
// handle stays as an obvious affordance for users who don't try the
// surface gesture.
export const panelHandle = style({
flexShrink: 0,
height: toRem(20),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'grab',
touchAction: 'none',
selectors: {
'&:active': { cursor: 'grabbing' },
},
});
export const panelHandleBar = style({
width: toRem(36),
height: toRem(4),
borderRadius: toRem(4),
backgroundColor: color.Surface.ContainerLine,
});
// 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%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
position: 'relative',
background: 'transparent',
border: 'none',
padding: 0,
});
export const avatarFullImage = style({
width: '100%',
height: '100%',
objectFit: 'cover',
});
export const avatarFullFallback = style({
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: toRem(96),
fontWeight: 600,
color: color.Surface.Container,
textTransform: 'uppercase',
});
// === Header viewport === Holds the chat header as the silhouette's
// 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
// `<Header size="600">` 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({
flexShrink: 0,
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
// 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',
});