349 lines
12 KiB
TypeScript
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)}`,
|
|
});
|