373 lines
14 KiB
TypeScript
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,
|
|
},
|
|
},
|
|
});
|