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

373 lines
14 KiB
TypeScript

import { keyframes, style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
// Desktop wrapper for the resizable thread drawer. Sizing and the
// absolutely-positioned resize handle live here; the inner aside
// (`ThreadDrawer` below) owns the rounded TL/BL carves + bg.
// `overflow: visible` on the wrapper is critical — the handle sits
// at `left: -GAP_PX` inside the horseshoe void, and the aside's
// `overflow: hidden` would otherwise clip it.
export const ThreadDrawerResizable = style({
position: 'relative',
flexShrink: 0,
flexGrow: 0,
maxHeight: '100%',
minHeight: 0,
display: 'flex',
flexDirection: 'column',
});
// Resize handle mirror of `PageNavResizeHandle` — sits in the 12px
// horseshoe void to the LEFT of the drawer (page-nav's handle sits in
// the gap to the right of the nav). Same cosmetics: thin indicator bar
// that fades in on hover/focus and stretches/shrinks at the clamps.
export const ThreadDrawerResizeHandle = style({
position: 'absolute',
top: 0,
bottom: 0,
left: `-${toRem(VOJO_HORSESHOE_GAP_PX)}`,
width: toRem(VOJO_HORSESHOE_GAP_PX),
cursor: 'col-resize',
zIndex: 1,
background: 'transparent',
touchAction: 'none',
outline: 'none',
selectors: {
'&::before': {
content: '""',
position: 'absolute',
top: '50%',
left: '50%',
width: 2,
height: toRem(36),
transform: 'translate(-50%, -50%)',
borderRadius: 1,
backgroundColor: color.Surface.OnContainer,
opacity: 0,
transition:
'opacity 140ms ease, height 140ms ease, width 140ms ease, background-color 140ms ease',
},
'&:hover::before, &:focus-visible::before': {
opacity: 0.25,
},
'&:focus-visible::before': {
backgroundColor: color.Primary.Main,
opacity: 0.45,
},
'&[data-dragging="true"]::before': {
opacity: 0.55,
height: toRem(48),
backgroundColor: color.Primary.Main,
},
'&[data-dragging="true"][data-at-min="true"]::before': {
height: toRem(28),
width: 3,
opacity: 0.85,
},
'&[data-dragging="true"][data-at-max="true"]::before': {
height: toRem(76),
width: 2,
opacity: 0.9,
},
},
});
// Layout copies element-web `_ThreadPanel.pcss:73-84`:
// `max-height: 100%` clamps the column to the parent's viewport-bound
// height; `min-height: 0` on the inner Scroll wrapper lets it shrink
// under content (otherwise flex-children grow with content and push
// the composer off-screen — bug observed on cold-load with many
// replies). Comment in element-web: «don't displace the composer».
//
// Horseshoe: rounded TL/BL mirrors the page-nav <-> chat and chat <->
// profile/media seams. Room.tsx paints the 12px void gap to the left
// of the drawer (`showThreadHorseshoe`) and the chat-column gets an
// explicit Background bg so the void can't bleed through.
//
// Sizing (`width`, `flexShrink`) was moved to `ThreadDrawerResizable`
// so the aside fills its resizable wrapper and the wrapper owns the
// flex-row contract with Room.tsx.
export const ThreadDrawer = style({
flexGrow: 1,
width: '100%',
display: 'flex',
flexDirection: 'column',
// Match the chat surface (`RoomView.tsx` paints
// `color.SurfaceVariant.Container` as its Page bg). Bubbles inside
// the drawer are `color.Surface.Container` so the «darker card on
// lighter surface» contrast reads identically to the main timeline.
backgroundColor: color.SurfaceVariant.Container,
minHeight: 0,
overflow: 'hidden',
borderTopLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
borderBottomLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
});
// Mobile push: drawer occupies the whole content column, not a side pane.
// `padding-top: var(--vojo-safe-top)` keeps the drawer's `ThreadDrawerHeader`
// (back arrow + thread title + counter) clear of the Android status-bar
// icons. `#root` no longer reserves the inset itself (`src/index.css`),
// and the chat-column wrapper that hosts this drawer on mobile has no
// safe-top of its own (the silhouette in `RoomViewProfilePanel` owns the
// inset for the chat header — but the thread drawer mounts as a sibling
// and would otherwise land directly under the system icons). The desktop
// `ThreadDrawer` style sits inside the chat row that has `env()` left/right
// only, so it inherits no top inset and doesn't need this padding (and
// would just leave dead space below the chat-column-wrapper's chrome).
export const ThreadDrawerMobile = style({
flexGrow: 1,
width: '100%',
maxHeight: '100%',
display: 'flex',
flexDirection: 'column',
// Same chat-surface tone as the desktop drawer above.
backgroundColor: color.SurfaceVariant.Container,
minHeight: 0,
paddingTop: 'var(--vojo-safe-top, 0px)',
});
export const ThreadDrawerHeader = style({
flexShrink: 0,
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
});
// Critical pair: `flexGrow: 1` lets the scroll wrapper take remaining
// space, `minHeight: 0` is what allows it to actually SHRINK under
// content (without it, flex-children grow to natural content size →
// composer pushed off viewport). `overflow: hidden` clips so the
// inner folds `<Scroll>` (which is `overflow: auto`) is the only
// scroller. Same trio that element-web uses on the timeline wrapper.
export const ThreadDrawerScroll = style({
flexGrow: 1,
minHeight: 0,
overflow: 'hidden',
position: 'relative',
});
// M4a: top padding bumped from S400 (16px) to S700 (32px). `<Message>`'s
// action bar (`MessageOptionsBase`, top: -30px in styles.css.ts:8-16)
// sits 30px above its row. Mid-list rows have the previous row's height
// absorbing that negative position, but the FIRST row (root event)
// only has this container's top padding above it — S400 wasn't enough,
// so the bar clipped at the scroll viewport's top edge on hover. S700
// gives the bar 32px of clearance, enough to render the ~28-32px hover
// bar fully on the root.
//
// Left/right padding zeroed: `ChannelRow` already provides an S400
// (16px) horizontal gutter per row, and stacking two S400 paddings
// pushed the avatar 32px from the drawer edge — a perceptibly off
// «double-gutter» look in the narrow side pane. With this gutter
// removed, the avatar sits at row-padding (16px) from the drawer's
// rounded edge, matching the channels main-timeline rhythm.
export const ThreadDrawerContent = style({
display: 'flex',
flexDirection: 'column',
gap: config.space.S400,
paddingTop: config.space.S700,
paddingBottom: config.space.S400,
});
// Assistant transcript variant: the channels `<Message>` hover action bar (which the S700 top
// pad above clears) never renders here (assistant rows are plain divs), so the 32px would just be
// dead space under the header. Tighten it to a small, comfortable gap.
//
// Centring: the AI surface mounts this drawer with `variant="mobile"` even on desktop (it fills
// the bot content column, not a side pane), so on a wide web viewport the transcript would spread
// edge-to-edge — the user complaint that messages are "thrown into the corners". Cap it to the
// 960px bridge band (apps/widget-telegram `.app`) and centre it, with the same 40px desktop gutter
// the host hero uses (BotShell.HeroInner) so the bot's text lines up under the hero name. The row
// styles below drop their own horizontal padding so the gutter lives only here. On narrow screens
// the cap is inert and the 16px mobile gutter matches the previous layout — native is unchanged.
export const ThreadDrawerContentAssistant = style([
ThreadDrawerContent,
{
paddingTop: config.space.S400,
width: '100%',
maxWidth: toRem(960),
marginLeft: 'auto',
marginRight: 'auto',
boxSizing: 'border-box',
// 12px horseshoe-gap on mobile (aligns the bot text with the hero name + the composer, which
// both use it), 40px hero gutter on desktop. Matches ThreadComposerAssistant / ComposerWrap.
paddingLeft: toRem(VOJO_HORSESHOE_GAP_PX),
paddingRight: toRem(VOJO_HORSESHOE_GAP_PX),
'@media': {
'screen and (min-width: 600px)': {
paddingLeft: toRem(40),
paddingRight: toRem(40),
},
},
},
]);
export const ThreadDivider = style({
height: 1,
backgroundColor: color.Surface.ContainerLine,
margin: `0 ${config.space.S400}`,
flexShrink: 0,
});
// Two-line drawer header caption + subtitle (M3 polish). The caption is
// uppercase tracking-wide ALL-CAPS «ТРЕД», the subtitle is the channel
// reference «в #channel».
export const ThreadDrawerHeaderCaption = style({
textTransform: 'uppercase',
letterSpacing: '0.08em',
fontWeight: 600,
color: color.SurfaceVariant.OnContainer,
opacity: 0.6,
});
export const ThreadDrawerHeaderSubtitle = style({
color: color.Surface.OnContainer,
fontWeight: 500,
});
// Counter row between the root and replies: dot + «N ОТВЕТОВ» uppercase
// label. Replaces the static 1-px divider when at least one reply exists.
export const ThreadCounterRow = style({
display: 'flex',
alignItems: 'center',
gap: config.space.S200,
padding: `${config.space.S100} ${config.space.S400}`,
borderTop: `1px solid ${color.Surface.ContainerLine}`,
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
flexShrink: 0,
});
export const ThreadCounterDot = style({
flexShrink: 0,
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: color.Primary.Main,
});
export const ThreadCounterText = style({
textTransform: 'uppercase',
letterSpacing: '0.08em',
fontSize: '11px',
fontWeight: 600,
color: color.SurfaceVariant.OnContainer,
opacity: 0.7,
});
// Thread composer wrap — geometry mirrors the personal-chat composer
// in `RoomView.tsx`: a 12px outer pad on the sides + bottom (matches
// the horseshoe void gap) frames a card whose visual chrome is owned
// by `ChatComposer` from `RoomView.css.ts`. The class itself is
// reapplied to the inner wrap in `ThreadDrawer.tsx` so the same
// `globalStyle` rules (`Surface.Container` bg, 32px radius, dark
// touch-hover gate) reach the Editor inside.
export const ThreadComposer = style({
flexShrink: 0,
padding: `0 ${toRem(VOJO_HORSESHOE_GAP_PX)} ${toRem(VOJO_HORSESHOE_GAP_PX)}`,
});
// Assistant (AI bot) composer: centred in the same 960px bridge band as the transcript so the
// input form width matches the messages on wide web viewports (the user's "input form sized to the
// bridge content width" ask). Desktop gets the 40px hero gutter; mobile/native keep the 12px
// horseshoe-void padding the base ThreadComposer uses, so native is unchanged.
export const ThreadComposerAssistant = style([
ThreadComposer,
{
width: '100%',
maxWidth: toRem(960),
marginLeft: 'auto',
marginRight: 'auto',
boxSizing: 'border-box',
'@media': {
'screen and (min-width: 600px)': {
paddingLeft: toRem(40),
paddingRight: toRem(40),
},
},
},
]);
// Bubble chrome itself lives in `Channel.css.ts` and applies via the
// row's `data-bubble="true"` marker (set by `ChannelLayout` when
// `headerInBubble` is enabled). Both the thread drawer and the
// channels main timeline opt in, so the look is shared.
export const ThreadEmptyState = style({
padding: `${config.space.S500} ${config.space.S300}`,
textAlign: 'center',
});
export const ThreadErrorState = style({
padding: config.space.S400,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: config.space.S300,
});
// --- Assistant (ChatGPT-style) transcript -----------------------------------------
// Used when ThreadDrawer is mounted with messageStyle="assistant" (the AI bot surface).
// The bot's turn is full-width plain text; the user's turn is a right-aligned bubble.
// Bot reply: no avatar/name/timestamp, just the rendered body. The horizontal gutter is owned by
// `ThreadDrawerContentAssistant` (the centred 960px band). The text column itself is capped to a
// ~768px reading measure (~70-75ch, ChatGPT-style) and left-aligned within the band, so long bot
// prose doesn't stretch to 100+ chars/line on a wide desktop. On mobile the band is already < 768
// so the cap is inert. User bubbles (right-aligned) keep the full band width.
export const AssistantBotRow = style({
width: '100%',
maxWidth: toRem(768),
color: color.Surface.OnContainer,
});
// User turn: right-aligned row holding a bubble. Gutter lives on the centred content band above.
export const AssistantUserRow = style({
display: 'flex',
justifyContent: 'flex-end',
});
export const AssistantUserBubble = style({
maxWidth: '85%',
minWidth: 0,
padding: `${config.space.S200} ${config.space.S400}`,
borderRadius: config.radii.R400,
// Match the input-form tone (RoomView.css `ChatComposer .Editor` = Surface.Container, the dark
// card the user types into) rather than the brand lavender, so a sent bubble reads as the same
// surface as the composer it came from.
backgroundColor: color.Surface.Container,
color: color.Surface.OnContainer,
});
// "Bot is typing" dots, shown after the last turn while the bot composes a reply.
const typingBlink = keyframes({
'0%, 80%, 100%': { opacity: 0.2, transform: 'translateY(0)' },
'40%': { opacity: 1, transform: `translateY(-${toRem(2)})` },
});
export const TypingDots = style({
display: 'inline-flex',
alignItems: 'center',
gap: toRem(4),
padding: `${config.space.S200} 0`,
});
export const TypingDot = style({
width: toRem(6),
height: toRem(6),
borderRadius: '50%',
backgroundColor: color.Surface.OnContainer,
opacity: 0.2,
animationName: typingBlink,
animationDuration: '1.2s',
animationIterationCount: 'infinite',
selectors: {
'&:nth-child(2)': { animationDelay: '0.2s' },
'&:nth-child(3)': { animationDelay: '0.4s' },
},
'@media': {
'(prefers-reduced-motion: reduce)': {
animationName: 'none',
opacity: 0.5,
},
},
});