vojo/src/app/components/message/layout/Channel.css.ts

234 lines
8.5 KiB
TypeScript

import { globalStyle, style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
// 36px circular avatar — a notch above folds `Avatar size="200"` (32px)
// for visual weight matching the channels mockup. Consumers override
// the folds preset via inline style; the shared `CHANNEL_AVATAR_PX`
// constant keeps the CSS slot width and the inline override in sync.
export const CHANNEL_AVATAR_PX = 36;
const ChannelAvatarWidth = toRem(CHANNEL_AVATAR_PX);
export const ChannelRow = style({
display: 'flex',
alignItems: 'flex-start',
gap: config.space.S300,
// S200 (8px) sits inside MessageBase's S400 (16px) left pad, so the
// avatar's left edge lands ~24px from the screen edge — tighter than
// the previous 32px (S400+S400) without crowding the bubble cluster.
// Mirror on the right for symmetric breathing room.
paddingLeft: config.space.S200,
paddingRight: config.space.S200,
paddingTop: config.space.S100,
paddingBottom: config.space.S100,
minWidth: 0,
// Hover bg subtle so adjacent rows still read as distinct units even
// without bubble borders. `@media (hover: hover)` keeps this inert on
// touch where there's no pointer to follow.
'@media': {
'(hover: hover) and (pointer: fine)': {
selectors: {
'&:hover': {
backgroundColor: color.SurfaceVariant.Container,
},
},
},
},
});
// Fixed-width slot keeps the body column aligned across collapsed rows
// (where `avatar` is `undefined` — the slot still occupies `ChannelAvatarWidth`,
// so the body's left edge stays put).
export const ChannelAvatarSlot = style({
width: ChannelAvatarWidth,
flexShrink: 0,
display: 'flex',
justifyContent: 'center',
paddingTop: toRem(2),
});
export const ChannelBody = style({
flex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
gap: config.space.S100,
});
export const ChannelHeader = style({
display: 'flex',
alignItems: 'baseline',
gap: config.space.S200,
minWidth: 0,
});
export const ChannelMessageBody = style({
minWidth: 0,
});
export const ChannelReactions = style({
minWidth: 0,
marginTop: config.space.S100,
});
export const ChannelThreadSummary = style({
minWidth: 0,
marginTop: config.space.S100,
});
// Horizontal line + centered label. Spans the full row width including
// the avatar slot so the line reads as a section break, not a per-message
// chrome element.
export const ChannelDayDividerRoot = style({
display: 'flex',
alignItems: 'center',
gap: config.space.S300,
paddingLeft: config.space.S400,
paddingRight: config.space.S400,
paddingTop: config.space.S400,
paddingBottom: config.space.S400,
});
export const ChannelDayDividerLine = style({
flex: 1,
height: '1px',
backgroundColor: color.SurfaceVariant.ContainerLine,
});
export const ChannelDayDividerLabel = style({
fontSize: toRem(11),
fontWeight: 600,
letterSpacing: '0.06em',
textTransform: 'uppercase',
color: color.SurfaceVariant.OnContainer,
opacity: 0.7,
flexShrink: 0,
});
// Sysline (membership / room.create / pinned-events). Compact single
// row aligned with the body gutter — the sysline sits where messages'
// body would, indented past the avatar slot, so the column reads
// continuous.
export const ChannelSysline = style({
// Indent past the avatar gutter so the sysline body aligns with the
// body column of the surrounding message rows (avatar + gap + row pad).
// Tracks `ChannelRow.paddingLeft/Right` (S200) in lockstep.
paddingLeft: `calc(${ChannelAvatarWidth} + ${config.space.S300} + ${config.space.S200})`,
paddingRight: config.space.S200,
paddingTop: config.space.S100,
paddingBottom: config.space.S100,
color: color.SurfaceVariant.OnContainer,
});
export const ChannelSyslineIcon = style({
flexShrink: 0,
opacity: 0.5,
});
export const ChannelSyslineBody = style({
minWidth: 0,
flex: 1,
});
// Bubble chrome applied when `ChannelLayout` is invoked with
// `headerInBubble` (thread drawer and channels main timeline pass it).
// Mirrors `StreamBubble` from the DM timeline so a channel row reads
// like a chat-bubble cluster: dark `Surface.Container` card with an
// asymmetric notch corner per `data-own`, sized `fit-content` so short
// bubbles shrink-wrap instead of stretching across the column.
// Reactions and the thread-summary card live as siblings of the body
// in `ChannelLayout`, so they stay OUTSIDE the bubble — identical
// composition to Stream. The `[data-bubble="true"]` row marker keeps
// the un-bubbled channel/sysline layout (pre-redesign callers) opt-in
// rather than forcing the look on every consumer.
globalStyle(`${ChannelRow}[data-bubble="true"] ${ChannelMessageBody}`, {
backgroundColor: color.Surface.Container,
color: color.SurfaceVariant.OnContainer,
border: `1px solid ${color.Surface.ContainerLine}`,
paddingTop: config.space.S200,
paddingBottom: config.space.S200,
paddingLeft: toRem(15),
paddingRight: toRem(15),
display: 'inline-block',
width: 'fit-content',
maxWidth: '100%',
minWidth: 0,
position: 'relative',
zIndex: 1,
// Clips the thread-summary footer's hover bg against the bubble's
// rounded BR/BL corners — without it the rectangular hover paint
// punches past the curve. No outflow content lives inside the bubble
// (option bar, reactions are siblings on the row) so clipping is
// safe.
overflow: 'hidden',
});
// Asymmetric corner per `data-own` — own messages flatten BOTTOM-LEFT
// (4px), incoming messages flatten TOP-LEFT. Same pattern as
// `StreamBubble.own`/`StreamBubble.others`.
globalStyle(`${ChannelRow}[data-bubble="true"][data-own="true"] ${ChannelMessageBody}`, {
borderRadius: `${toRem(16)} ${toRem(16)} ${toRem(16)} ${toRem(4)}`,
});
globalStyle(`${ChannelRow}[data-bubble="true"][data-own="false"] ${ChannelMessageBody}`, {
borderRadius: `${toRem(4)} ${toRem(16)} ${toRem(16)} ${toRem(16)}`,
// Peer (not-own) bubble bg for the channel layout — its own var. (The 1-1
// Stream layout's incoming bubble instead binds to color.Surface.Container,
// the composer surface.) Covers channels main timeline AND thread drawer
// (both pass `headerInBubble`, so `data-bubble="true"` fires).
backgroundColor: 'var(--vojo-peer-bubble-bg)',
});
// Small gap so the in-bubble header (username + time) doesn't sit flush
// against the first line of message text. Matches the Stream layout's
// `StreamName` 2px marginBottom.
globalStyle(`${ChannelRow}[data-bubble="true"] ${ChannelHeader}[data-in-bubble="true"]`, {
marginBottom: toRem(2),
});
// Thread-summary footer rendered INSIDE the bubble (rather than as a
// separate pill below). Negative L/R margin (matches the bubble's
// `paddingLeft/Right: 15px`) stretches the wrapper to the bubble's
// inner border edge so the 1px top rule reads as a section divider
// spanning the whole bubble. Negative `marginBottom` cancels the
// bubble's S200 bottom pad so the footer flushes against the bubble's
// rounded bottom edge — bubble + summary read as one card with a
// horizontal rule splitting them.
//
// The footer body keeps no own border/radius — it inherits the bubble's
// bottom corners via clipping (`ChannelMessageBody` itself doesn't
// `overflow: hidden`, but the rounded bottom of the bubble visually
// caps the footer anyway because the divider line never reaches the
// curved corner pixel).
export const ChannelBubbleThreadSummary = style({
marginTop: config.space.S200,
marginLeft: toRem(-15),
marginRight: toRem(-15),
marginBottom: `calc(-1 * ${config.space.S200})`,
borderTop: `1px solid ${color.Surface.ContainerLine}`,
});
// Footer button — strip the original ThreadSummaryCard pill chrome
// (own bg, radius, padding, max-width) so it reads as a flush bubble
// footer. Click target expands to the full footer width. Hover paints
// a subtle `SurfaceVariant.Container` shade that contrasts against
// the bubble's `Surface.Container` bg, signalling tappable footer
// without the pill silhouette returning.
globalStyle(`${ChannelBubbleThreadSummary} > button`, {
display: 'flex',
width: '100%',
maxWidth: 'none',
borderRadius: 0,
backgroundColor: 'transparent',
padding: `${config.space.S200} ${toRem(15)}`,
});
globalStyle(`${ChannelBubbleThreadSummary} > button:hover`, {
backgroundColor: color.SurfaceVariant.Container,
});
globalStyle(`${ChannelBubbleThreadSummary} > button:focus-visible`, {
// Inset the focus ring slightly so it doesn't punch through the
// bubble's rounded bottom corners on the BR/BL when the row is
// either own or incoming.
outlineOffset: toRem(-2),
});