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