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

654 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<typeof MessageBase>;
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 `<span>`, 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
// `<Reactions>` 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 `<Time>` placeholder rendered by Stream.tsx so the
// `auto` track sizes to the same width as the surrounding message rows.
export const StreamDayRoot = recipe({
base: {
position: 'relative',
display: 'grid',
gridTemplateColumns: 'auto auto 1fr',
columnGap: StreamRowGapVar,
paddingLeft: StreamRowPadVar,
alignItems: 'center',
paddingTop: toRem(10),
paddingBottom: toRem(10),
paddingRight: config.space.S400,
},
variants: {
compact: {
false: {
vars: {
[StreamRowPadVar]: StreamRowPadDesktop,
[StreamRowGapVar]: StreamRowGapDesktop,
},
},
true: {
vars: {
[StreamRowPadVar]: StreamRowPadMobile,
[StreamRowGapVar]: StreamRowGapMobile,
},
marginLeft: `calc(-1 * ${config.space.S400})`,
marginRight: `calc(-1 * ${config.space.S200})`,
paddingRight: 0,
},
},
},
defaultVariants: { compact: false },
});
// Off-screen placeholder timestamp inside the day divider's track 1 —
// invisible, but takes the same auto-width as the live timestamps in
// surrounding message rows so the grid columns align. Override the
// header time's paddingTop because the placeholder needs no header-line
// alignment (the day divider is already vertically centred on the row).
export const StreamDayTimePlaceholder = style({
visibility: 'hidden',
pointerEvents: 'none',
paddingTop: 0,
});
export const StreamDayLabel = style({
fontSize: toRem(11),
textTransform: 'uppercase',
letterSpacing: toRem(1),
fontWeight: 600,
color: color.Surface.OnContainer,
opacity: 0.55,
whiteSpace: 'nowrap',
flexShrink: 0,
fontFamily:
'"JetBrains Mono Variable", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
});
// Day-divider dot — abs-positioned in DotColumn so it centres on the
// (StreamDotSize-wide) column regardless of its own larger size; the
// extra 3.1 px (= dayDot 12.1 - StreamDot 9) overflows symmetrically into
// the columnGap on each side.
export const StreamDayDot = style({
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: StreamDayDotSize,
height: StreamDayDotSize,
borderRadius: '50%',
background: color.Primary.MainHover,
boxShadow: `0 0 0 4px ${color.Surface.Container}`,
pointerEvents: 'none',
zIndex: 2,
});
// `─── Сегодня ───` line — grid item in track 3. Flex row with two flex:1
// hairlines and the label centered between them. The dot's overflow into
// the columnGap means the leftmost line segment naturally starts past
// the dot's right edge.
export const StreamDayLineWrap = style({
display: 'flex',
alignItems: 'center',
gap: config.space.S300,
});
export const StreamDayLineSegment = style({
flex: 1,
height: 1,
background: color.Surface.ContainerLine,
minWidth: toRem(8),
});
export const MessageTextBody = recipe({
base: {
wordBreak: 'break-word',
},
variants: {
preWrap: {
true: {
whiteSpace: 'pre-wrap',
},
},
jumboEmoji: {
true: {
fontSize: '1.504em',
lineHeight: '1.4962em',
},
},
emote: {
true: {
color: color.Success.Main,
fontStyle: 'italic',
},
},
},
});
export type MessageTextBodyVariants = RecipeVariants<typeof MessageTextBody>;