import { createVar, globalStyle, keyframes, style, styleVariants } from '@vanilla-extract/css'; import { recipe, RecipeVariants } from '@vanilla-extract/recipes'; import { DefaultReset, color, config, toRem } from 'folds'; const SpacingVar = createVar(); const SpacingVariant = styleVariants({ '0': { vars: { [SpacingVar]: config.space.S0, }, }, '100': { vars: { [SpacingVar]: config.space.S100, }, }, '200': { vars: { [SpacingVar]: config.space.S200, }, }, '300': { vars: { [SpacingVar]: config.space.S300, }, }, '400': { vars: { [SpacingVar]: config.space.S400, }, }, '500': { vars: { [SpacingVar]: config.space.S500, }, }, }); const highlightAnime = keyframes({ '0%': { backgroundColor: color.Primary.Container, }, '25%': { backgroundColor: color.Primary.ContainerActive, }, '50%': { backgroundColor: color.Primary.Container, }, '75%': { backgroundColor: color.Primary.ContainerActive, }, '100%': { backgroundColor: color.Primary.Container, }, }); const HighlightVariant = styleVariants({ true: { animation: `${highlightAnime} 2000ms ease-in-out`, animationIterationCount: 'infinite', }, }); const SelectedVariant = styleVariants({ true: { backgroundColor: color.Surface.ContainerActive, }, }); const AutoCollapse = style({ selectors: { [`&+&`]: { marginTop: 0, }, }, }); export const MessageBase = recipe({ base: [ DefaultReset, { marginTop: SpacingVar, padding: `${config.space.S100} ${config.space.S200} ${config.space.S100} ${config.space.S400}`, borderRadius: `0 ${config.radii.R400} ${config.radii.R400} 0`, }, ], variants: { space: SpacingVariant, collapse: { true: { marginTop: 0, }, }, autoCollapse: { true: AutoCollapse, }, highlight: HighlightVariant, selected: SelectedVariant, }, defaultVariants: { space: '400', }, }); export type MessageBaseVariants = RecipeVariants; export const AvatarBase = style({ paddingTop: toRem(4), transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)', display: 'flex', alignSelf: 'start', selectors: { '&:hover': { transform: `translateY(${toRem(-2)})`, }, }, }); export const ModernBefore = style({ minWidth: toRem(36), }); export const Username = style({ overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', selectors: { 'button&': { cursor: 'pointer', }, 'button&:hover, button&:focus-visible': { textDecoration: 'underline', }, }, }); export const UsernameBold = style({ fontWeight: 550, }); // Stream layout (DM redesign — see docs/plans/dm_1x1_redesign.md §6.5b). // // Symmetric three-gap layout, expressed as a 3-track CSS grid: // // ┌─G─┬─time─┬─G─┬─dot─┬─G─┬───── bubble (1fr) ─────────┐ // row row // left right // // where G = `column-gap` = `padding-left` of the row root. Both inset // values come from the same custom property, so the gap from the row's // left edge to the timestamp text equals the gap between time and dot // equals the gap between dot and bubble — symmetric by construction, no // per-side magic constants. // // Track 1 is `auto`, so the browser sizes it to the rendered timestamp // width — no JS measurement, no locale branching. 24h "16:58" auto-sizes // to ~33 px and the whole row block contracts left; AM/PM "11:30 PM" // auto-sizes to ~52 px and the block expands right. Both keep the same // `G` gaps; the dot and bubble shift together as one unit. // // Two gap dimensions, set per-breakpoint via the `compact` recipe // variant on the row roots: // // - StreamRowPadVar → screen→time gap (the row's `padding-left`) // - StreamRowGapVar → time→dot and dot→bubble gaps (the `column-gap`) // // Splitting these lets the inter-element gaps tighten on desktop and // loosen on mobile without disturbing the screen-edge anchor (which the // user dialled in earlier and asked to keep). // // Mobile: pad = S100 (minimal screen-edge anchor); gap = 2 × S100 = S200 // (the user asked to double the inter-element gap on native). // Desktop: pad = S500 (≈ 0.5 cm PageNav clearance, unchanged); gap = // S500 / 1.1 ≈ 18.2 px (the user asked to shrink the desktop inter-element // gap by 1.1× — keeps the layout tighter without dropping a whole token). const StreamRowPadVar = createVar(); const StreamRowGapVar = createVar(); const StreamRowPadMobile = config.space.S100; const StreamRowPadDesktop = config.space.S500; const StreamRowGapMobile = config.space.S200; const StreamRowGapDesktop = `calc(${config.space.S500} / 1.1)`; const StreamDotSize = toRem(9); const StreamSyslineDotSize = toRem(6); // Slightly larger than message dots so day markers read as the visual // anchor of the `─── Сегодня ───` line. const StreamDayDotSize = toRem(12.1); const StreamRailLineWidth = '1px'; const StreamBubbleBorderWidth = '1px'; const StreamTimeLineHeight = toRem(13); const StreamRailBridgeY = config.space.S400; // Vertical centre of the bubble's header text from the row's content-area // top. = bubble.borderTop (1) + bubble.paddingTop (S200) + line.T200 / 2. // Used to push the timestamp and the dot down in their grid cells so // they read on the same baseline as the Username component inside the // bubble. Rail-end's height also adds `S100` back to account for the // rail's negative `top` offset (it starts above the row outer edge). const StreamHeaderInnerCenterY = `calc(${StreamBubbleBorderWidth} + ${config.space.S200} + (${config.lineHeight.T200} / 2))`; export const StreamRoot = recipe({ base: { position: 'relative', display: 'grid', // Three tracks: time (auto = formatted-timestamp width) | dot column // (auto = dot diameter) | bubble (1fr). The grid auto-sizes track 1 // to the rendered timestamp width — locale-agnostic, no JS measure. gridTemplateColumns: 'auto auto 1fr', columnGap: StreamRowGapVar, paddingLeft: StreamRowPadVar, alignItems: 'flex-start', paddingTop: config.space.S100, paddingBottom: config.space.S100, paddingRight: config.space.S400, }, variants: { compact: { false: { vars: { [StreamRowPadVar]: StreamRowPadDesktop, [StreamRowGapVar]: StreamRowGapDesktop, }, }, // Mobile: cancel MessageBase's S400/S200 horizontal padding so the // row spans edge-to-edge, then padding-left alone provides the // minimal screen→time anchor inside the row. true: { vars: { [StreamRowPadVar]: StreamRowPadMobile, [StreamRowGapVar]: StreamRowGapMobile, }, marginLeft: `calc(-1 * ${config.space.S400})`, marginRight: `calc(-1 * ${config.space.S200})`, paddingRight: 0, }, }, }, defaultVariants: { compact: false }, }); // Sysline timestamp. Now a regular grid item in track 1 — sized to its // content width like the message-row timestamp, just inheriting the // row's `align-items: center` so the single-line sysline reads on a // shared centreline with the rest of the row. export const StreamTimeColumn = style({ fontSize: toRem(11), fontVariantNumeric: 'tabular-nums', color: color.Surface.OnContainer, opacity: 0.55, whiteSpace: 'nowrap', fontFamily: '"JetBrains Mono Variable", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', }); globalStyle(`${StreamTimeColumn} time`, { display: 'block', fontFamily: 'inherit', fontSize: toRem(11), lineHeight: StreamTimeLineHeight, }); // DotColumn — grid track 2. Always StreamDotSize wide regardless of row // type so message / sysline / day-divider rows all hit the same vertical // X for the rail line — otherwise the rail would visibly kink at row // boundaries with different dot sizes. Stretches to row content height // so the rail abs-child can extend through the row. export const StreamDotColumn = style({ position: 'relative', alignSelf: 'stretch', width: StreamDotSize, }); // Per-row rail segment. Lives inside DotColumn and is horizontally // centred on the dot column (= the dot's centre). Top/bottom extend // `bridge + row.paddingTop/Bottom` past the DotColumn bounds so consecutive // rails meet across the row's vertical padding plus MessageBase's marginTop. // DotColumn stretches to the row content area, so its top/bottom = row // content top/bottom; we extend past those to reach row outer ± bridge. export const StreamRail = style({ position: 'absolute', top: `calc(-1 * (${StreamRailBridgeY} + ${config.space.S100}))`, bottom: `calc(-1 * (${StreamRailBridgeY} + ${config.space.S100}))`, left: '50%', transform: 'translateX(-50%)', width: StreamRailLineWidth, background: color.Surface.ContainerLine, pointerEvents: 'none', zIndex: 0, }); // Rail-end / rail-start variants — stop the rail at the dot rather than // extending past it. `StreamHeaderInnerCenterY` is the Y from DotColumn // top to the dot centre (DotColumn top = row content top = excludes // row.paddingTop); subtract the dot radius so the rail meets the dot edge. export const StreamRailEnd = style({ bottom: 'auto', // Top stays at the negative bridge offset; height takes the rail down // to the dot's centre line. height: `calc(${StreamRailBridgeY} + ${config.space.S100} + ${StreamHeaderInnerCenterY})`, }); export const StreamRailStart = style({ top: StreamHeaderInnerCenterY, }); export const StreamRailSingle = style({ display: 'none', }); // Two-layer span: outer Halo paints a solid bg disk + ring that masks the // rail line behind the dot regardless of opacity; inner Fill carries the // author colour and the read-state opacity. // // Halo is a regular in-flow grid-cell child (no `position: absolute`) // that sits in DotColumn. Its block-axis position inside the column is // controlled per-row-type by composed classes below (Header* for message // rows, Sysline* for syslines). `display: block` is REQUIRED — the // rendered element is a ``, and `width` / `height` / `margin-top` // are silently ignored on inline-display elements (so the dot would // collapse to zero size without it). Sysline / day-row variants override // to `position: absolute`, which auto-blockifies; message rows rely on // this declaration. export const StreamDotHalo = style({ display: 'block', width: StreamDotSize, height: StreamDotSize, borderRadius: '50%', background: color.Surface.Container, boxShadow: `0 0 0 3px ${color.Surface.Container}`, pointerEvents: 'none', position: 'relative', zIndex: 2, }); // Sysline dot — smaller, abs-positioned at DotColumn centre so it // vertically centres on the sysline row regardless of DotColumn height. export const StreamSyslineDotHalo = style({ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', width: StreamSyslineDotSize, height: StreamSyslineDotSize, }); // Sysline rail-end / rail-start — sysline DotColumn doesn't stretch, so // the rail extends from the dot centre by bridge + half DotColumn height. // We can't read DotColumn height at compile time, but `50%` of the rail's // own absolute-positioned containing block (= DotColumn) gives the same // effect. // Sysline rail-end / rail-start variants. The rail's containing block is // DotColumn (which stretches via `align-self: stretch` regardless of the // parent's `align-items`). Heights/tops are measured from DotColumn's // padding-edge top — `top: -(bridge + S100)` lifts the rail's top-zero // position by `bridge + paddingTop` past the row outer edge, so the // terminating end must add `S100` back when capping at the dot centre // (which is at `50%` of DotColumn under `align-items: center`). export const StreamSyslineRailEnd = style({ bottom: 'auto', height: `calc(${StreamRailBridgeY} + ${config.space.S100} + 50%)`, }); export const StreamSyslineRailStart = style({ top: '50%', }); // Message-row dot in DotColumn. Pushes the dot down by the same Y the // bubble's header text occupies, so the dot centres on the Username line. // Set as marginTop because Halo is in-flow. export const StreamHeaderDotHalo = style({ marginTop: `calc(${StreamHeaderInnerCenterY} - (${StreamDotSize} / 2))`, }); export const StreamDotFill = style({ position: 'absolute', inset: 0, borderRadius: '50%', pointerEvents: 'none', // Smooth read↔unread fade — without it the state flip reads as a hard step. transition: 'opacity 220ms ease, filter 220ms ease', }); // Wrapper for grid track 3 — holds the bubble and the reactions row stacked. // `min-width: 0` lets fit-content bubbles shrink below their content's natural // size when the grid track is narrower than the bubble (otherwise they'd // overflow). export const StreamColumn = style({ gridColumn: 3, minWidth: 0, display: 'flex', flexDirection: 'column', alignItems: 'flex-start', }); // Reactions row sits below the bubble, outside its bg/border, so reactions // read as floating chips. Aligns to the bubble's left edge by inheriting // `align-items: flex-start` from StreamColumn. The inline marginTop on the // `` callsites in RoomTimeline supplies the spacing — flex // columns don't collapse margins so a wrapper margin would stack. export const StreamReactions = style({ maxWidth: '100%', minWidth: 0, }); export const StreamBubble = recipe({ base: { backgroundColor: color.SurfaceVariant.Container, color: color.SurfaceVariant.OnContainer, border: `${StreamBubbleBorderWidth} solid ${color.SurfaceVariant.ContainerLine}`, paddingTop: config.space.S200, paddingBottom: config.space.S200, minWidth: 0, maxWidth: toRem(720), position: 'relative', zIndex: 1, }, variants: { // Asymmetric notch — own: top-left flat, three corners R500. // Incoming: mirrored. own: { true: { borderRadius: `${toRem(4)} ${config.radii.R500} ${config.radii.R500} ${config.radii.R500}`, }, false: { borderRadius: `${config.radii.R500} ${config.radii.R500} ${config.radii.R500} ${toRem(4)}`, }, }, // Mobile fills the message column (block 100%); desktop fits content // (inline-block fit-content). Branched via useScreenSizeContext, not // CSS media queries — see docs/plans/dm_1x1_redesign.md §5.5. compact: { true: { display: 'block', width: '100%', paddingLeft: config.space.S300, paddingRight: config.space.S300, }, false: { display: 'inline-block', width: 'fit-content', maxWidth: '100%', paddingLeft: toRem(15), paddingRight: toRem(15), }, }, // Image messages: bubble becomes a transparent shell so the // StreamMediaImage child supplies the visible chrome instead. // `display: block, width: 100%` (NOT fit-content) so the bubble has a // definite width inherited from StreamColumn — required for the // child's `max-width: 100%` to clamp the image. With fit-content the // chain becomes circular (parent shrinks to child, child grows to // its explicit pixel width), and the image overflows past the // viewport on narrow screens. mediaMode: { true: { backgroundColor: 'transparent', border: 'none', borderRadius: 0, padding: 0, display: 'block', width: '100%', }, }, }, defaultVariants: { own: false, compact: false, mediaMode: false, }, }); export const StreamBubbleHeader = style({ position: 'relative', marginBottom: toRem(2), fontSize: toRem(11), lineHeight: config.lineHeight.T200, minHeight: config.lineHeight.T200, fontWeight: 600, display: 'flex', alignItems: 'center', gap: toRem(6), flexWrap: 'nowrap', }); // Message-row timestamp — grid item in track 1, content-sized. Pushed // down by the same Y as StreamHeaderDotHalo so timestamp / dot / Username // share one baseline. export const StreamHeaderTime = style({ paddingTop: `calc(${StreamHeaderInnerCenterY} - (${StreamTimeLineHeight} / 2))`, fontSize: toRem(11), lineHeight: StreamTimeLineHeight, fontVariantNumeric: 'tabular-nums', color: color.Surface.OnContainer, opacity: 0.55, whiteSpace: 'nowrap', fontFamily: '"JetBrains Mono Variable", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', }); globalStyle(`${StreamHeaderTime} time`, { display: 'block', fontFamily: 'inherit', fontSize: toRem(11), lineHeight: StreamTimeLineHeight, }); // Sysline — thin single-line state-event row inside Stream layout. // Composes with StreamRoot so the time / dot / content tracks line up // vertically with message rows above and below. Override align-items to // center because the sysline content is one line tall and reads best // vertically centred. export const StreamSysline = style({ alignItems: 'center', paddingTop: toRem(2), paddingBottom: toRem(2), }); export const StreamSyslineBody = style({ fontSize: toRem(11.5), color: color.Surface.OnContainer, opacity: 0.55, fontFamily: '"JetBrains Mono Variable", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0, }); // Day divider row — uses the SAME 3-track grid as message rows so its // dot and rail land on the exact same X as the dots/rails above and below // it (otherwise the rail would visibly kink at the day boundary). Track 1 // holds an invisible `