vojo/src/app/components/stream-header/StreamHeader.css.ts

349 lines
12 KiB
TypeScript

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