import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; import { color, config, toRem } from 'folds'; import { CHIP_GAP_PX, CURTAIN_BREATHER_PX, CURTAIN_RADIUS_PX, CURTAIN_SNAP_EASING, CURTAIN_SNAP_MS, HANDLE_HEIGHT_PX, TABS_ROW_PX, WEB_TABS_ROW_PX, } from './geometry'; // Stage. Position-relative anchor. The header itself paints the // light-blue backdrop; the curtain is layered ABOVE it via z-index. // // In pager mode the bg collapses to transparent so the pager's static // header (sitting behind the strip in DOM order) shows through every // pixel the curtain isn't covering. See // `mobile-tabs-pager/style.css.ts::pagerStaticHeader` for the full // curtain-overlay contract. The strip is tagged // `data-pager-pane="true"` in MobileTabsPager.tsx, which gates this // selector. export const stage = style({ position: 'relative', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', backgroundColor: color.SurfaceVariant.Container, selectors: { '[data-pager-pane="true"] &': { backgroundColor: 'transparent', }, }, }); // Header — always-rendered strip carrying tabs row + (optional) chip // reveal area + (optional) active form. The curtain slides on top of // the area BELOW the tabs row to cover/reveal those children. // // In pager mode the bg collapses to transparent for the same reason as // `stage` above — let the static pager header show through where the // curtain isn't. Chips have their own pill bg and the inline form is // composed of folds-styled inputs with their own backgrounds, so the // peek/form snaps stay visually opaque without this layer. export const header = style({ position: 'absolute', top: 0, left: 0, right: 0, display: 'flex', flexDirection: 'column', // Higher than `stage`, lower than `curtain` so the curtain occludes // everything below the tabs row when raised. zIndex: 1, backgroundColor: color.SurfaceVariant.Container, selectors: { '[data-pager-pane="true"] &': { backgroundColor: 'transparent', }, }, }); // Tabs row. Stays fully visible regardless of curtain position // because the curtain's `top` floor equals `TABS_ROW_PX` on native // (`WEB_TABS_ROW_PX` on web — see `geometry.ts::WEB_TABS_ROW_PX`). // // Web variant: shrink to `WEB_TABS_ROW_PX` (= 54 px = folds Header // `size="600"`) so the row reads at the same height as the right-pane // room `PageHeader`, AND own the 1 px divider rule as a // `border-bottom`. Putting the rule on `tabsRow` (not on the curtain // as a `border-top`) is load-bearing for pixel alignment: with the // global `* { box-sizing: border-box }` reset (`src/index.css`), // `tabsRow`'s 1 px bottom border lands at y=53→54 inside the 54 px // box — exactly where PageHeader's outlined border-bottom paints. If // the rule lived on the curtain's `border-top` at `top: 54`, it would // paint at y=54→55, off-by-one against the right pane. export const tabsRow = style({ flexShrink: 0, height: toRem(TABS_ROW_PX), display: 'flex', alignItems: 'center', padding: `0 ${toRem(8)}`, selectors: { '[data-platform="web"] &': { height: toRem(WEB_TABS_ROW_PX), borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, }, }, }); export const tabsCluster = style({ display: 'flex', alignItems: 'center', gap: toRem(4), alignSelf: 'stretch', }); export const iconsCluster = style({ display: 'flex', alignItems: 'center', gap: toRem(4), flexShrink: 0, }); // Curtain. Layered above the header (z-index higher). Its top edge // moves with the snap state (and live finger drag); its bottom edge // is anchored to the stage bottom so the curtain's `bottomPinned` // child (DirectSelfRow / WorkspaceFooter) stays glued to the visible // viewport bottom regardless of where the curtain's top is. // // On native, only the TOP corners are rounded: the bottom is meant // to read as continuous with the always-visible bottomPinned row // (DirectSelfRow is the curtain's last flex child) — adding // `borderBottomRadius` would crop the row's corners against the // curtain's `overflow: hidden`, which visually reads as «a light- // blue strip cuts into the row». // // Live finger tracking and snap commits both flow through React state // updates to `top` so the transition is always coordinated with the // rendered position — disabled during drag, restored on commit. // // Web variant (`[data-platform="web"]` on `stage`, set by // StreamHeader.tsx when `!isNativePlatform()`): there is no pin/peek // gesture, so the curtain is a purely static slab under the tabs row. // Drop ONLY the «card» rounding (top corners flat). The divider rule // at the seam is owned by `tabsRow.borderBottom` under the same // selector — that placement keeps the rule pixel-aligned with the // right-pane `PageHeader`'s outlined border (see `tabsRow` comment // above). The curtain bg stays `Background.Container` so the chat- // row rows (`NavItem variant="Background"`) keep blending into one // continuous list surface — if we made the curtain transparent the // rows would paint as dark cards over the lighter // `SurfaceVariant.Container` stage. export const curtain = style({ position: 'absolute', left: 0, right: 0, bottom: 0, zIndex: 2, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden', backgroundColor: color.Background.Container, borderTopLeftRadius: toRem(CURTAIN_RADIUS_PX), borderTopRightRadius: toRem(CURTAIN_RADIUS_PX), transition: `top ${CURTAIN_SNAP_MS}ms ${CURTAIN_SNAP_EASING}`, // Hint the compositor while the curtain is moving. Cheap since the // curtain is the only element in this stacking context that animates. willChange: 'top', selectors: { '[data-platform="web"] &': { borderTopLeftRadius: 0, borderTopRightRadius: 0, }, }, }); // Drag handle at the top of the curtain. Dedicated touch surface for // the pin / unpin gesture so it doesn't compete with the chat list's // vertical scroll. `touchAction: none` keeps the browser from claiming // the gesture for native scroll heuristics — our `touchmove` listener // in `useCurtainHandleGesture` drives every pixel of motion. // // Sits as the first flex child of the curtain so the list (or // DirectEmpty / equivalent placeholder) takes the remaining space // below it. `flexShrink: 0` locks the height so a long list doesn't // squash the hit-zone. export const handle = style({ flexShrink: 0, height: toRem(HANDLE_HEIGHT_PX), display: 'flex', alignItems: 'center', justifyContent: 'center', touchAction: 'none', }); // Visual «grabber» pill centred inside `handle`. Semi-transparent // foreground so the affordance reads as «draggable» without competing // with content beneath. Pure decoration — the parent `handle` div // captures the touch. // // State machine mirrors `PageNavResizeHandle` on desktop: a subtle // resting state, a more prominent «being dragged» state, and an even // more prominent «threshold reached, release to commit» state. The // state-driving `data-dragging` / `data-at-commit` attributes live on // the parent `handle` div (set by StreamHeader.tsx from the gesture // hook). Transition durations match the desktop handle (140ms ease) // so the two affordances feel related. export const handleBar = style({ width: toRem(40), height: toRem(4), borderRadius: toRem(2), backgroundColor: color.Background.OnContainer, opacity: 0.25, pointerEvents: 'none', transition: 'opacity 140ms ease, width 140ms ease, height 140ms ease, background-color 140ms ease', selectors: { // Dragging but threshold not yet reached: highlight, slight grow. '[data-dragging="true"] &': { opacity: 0.55, width: toRem(48), backgroundColor: color.Primary.Main, }, // Threshold reached during drag: full stretch + opacity. Releasing // here commits pin (or unpin). Reads as «yes, you're there». '[data-dragging="true"][data-at-commit="true"] &': { opacity: 0.9, width: toRem(64), height: toRem(5), backgroundColor: color.Primary.Main, }, }, }); // Wrapper around `bottomPinned` inside the curtain. Anchored to the // curtain's flex-bottom by virtue of being the last child. The TSX // collapses this slot to `{ height: 0, overflow: hidden }` when the // on-screen keyboard rises (via `VisualViewport.height` shrink) so // the row neither paints nor claims flex space above the keyboard. // Without this compensation, `interactive-widget=resizes-content` // (global viewport meta — load-bearing for the room composer) // shrinks the layout viewport, dragging every `bottom: 0` element // up over the inline form. The DirectSelfRow ending up immediately // above the keyboard would block the user's view of the form they're // typing into. export const bottomPinnedSlot = style({ flexShrink: 0, }); // Segment button (Direct / Channels / Bots). export const segment = recipe({ base: { appearance: 'none', border: 'none', background: 'transparent', color: color.Background.OnContainer, cursor: 'pointer', padding: `${toRem(8)} ${toRem(10)}`, borderRadius: toRem(8), font: 'inherit', fontSize: toRem(14), lineHeight: 1.2, display: 'inline-flex', alignItems: 'center', gap: toRem(8), whiteSpace: 'nowrap', fontWeight: 500, WebkitAppearance: 'none', }, variants: { active: { true: { fontWeight: 600 }, }, disabled: { true: { opacity: 0.45, cursor: 'default' }, }, }, }); // Active-state dot inside each segment. export const segmentDot = recipe({ base: { width: toRem(6), height: toRem(6), borderRadius: '50%', flexShrink: 0, }, variants: { active: { true: { backgroundColor: color.Primary.Main }, false: { backgroundColor: 'transparent' }, }, }, }); // Chip row — outer clip-strip. Both rows reveal together when the // user drags the curtain down to the `peek` snap. // // The `marginBottom` math is load-bearing for the snap-top // calculation: the resting `top` of `peek` lands the curtain exactly // where the next row would have begun, so the breather never "steals" // pixels from the next chip's paddingTop. Two different values: // - default (chip-to-chip): `CHIP_GAP_PX` — tighter, so the two // pills read as a related pair when both are revealed. // - `:last-child` (chip-to-curtain): `CURTAIN_BREATHER_PX` — wider, // so the curtain's rounded top has comfortable air above the // chip pill it lands above. export const chipRow = style({ height: toRem(56), marginBottom: toRem(CHIP_GAP_PX), paddingLeft: toRem(24), paddingRight: toRem(24), paddingTop: toRem(8), display: 'flex', alignItems: 'flex-start', selectors: { '&:last-child': { marginBottom: toRem(CURTAIN_BREATHER_PX), }, }, }); // The chip pill itself. export const chip = style({ appearance: 'none', border: 'none', display: 'flex', alignItems: 'center', gap: toRem(10), width: '100%', height: toRem(48), padding: `${toRem(8)} ${toRem(14)}`, borderRadius: toRem(20), font: 'inherit', fontSize: toRem(14), textAlign: 'left', cursor: 'pointer', backgroundColor: color.Background.Container, color: color.Background.OnContainer, WebkitAppearance: 'none', }); export const chipPlaceholder = style({ opacity: 0.65, whiteSpace: 'nowrap', }); // Active form area in the header. Outer is `position: relative`; the // inner mounted form fills it with `top: 0` so the form's first // element (input bar) sits flush at the line where chips would // otherwise live. export const formArea = style({ position: 'relative', flexShrink: 0, overflow: 'hidden', }); export const formInner = style({ position: 'absolute', top: 0, left: 0, right: 0, padding: `${toRem(8)} ${toRem(24)} ${toRem(12)}`, });