refactor(timeline): rebuild stream row as 3-track CSS grid with auto-sized time column so 24h and AM/PM render with consistent gaps without JS measurement

This commit is contained in:
heaven 2026-05-07 23:36:18 +03:00
parent 997375b307
commit d58e69d49f
3 changed files with 265 additions and 197 deletions

View file

@ -33,17 +33,18 @@ export function EventContent({ time, iconSrc, content, railStart, railEnd }: Eve
); );
// Sysline = thin one-line state-event row that lives ON the rail. // Sysline = thin one-line state-event row that lives ON the rail.
// Same 3-track grid as message rows (StreamRoot) — track 1 timestamp,
// track 2 dot column, track 3 body — so the dot's X aligns with the
// dots above and below it.
return ( return (
<div <div
className={classNames(layoutCss.StreamRoot({ compact }), layoutCss.StreamSysline)} className={classNames(layoutCss.StreamRoot({ compact }), layoutCss.StreamSysline)}
ref={rootRef} ref={rootRef}
> >
<div <div className={layoutCss.StreamTimeColumn} ref={timeRef}>
className={classNames(layoutCss.StreamTimeColumn, layoutCss.StreamSyslineTimeColumn)}
ref={timeRef}
>
{time} {time}
</div> </div>
<span className={layoutCss.StreamDotColumn} aria-hidden>
<span <span
className={classNames( className={classNames(
layoutCss.StreamRail, layoutCss.StreamRail,
@ -51,12 +52,10 @@ export function EventContent({ time, iconSrc, content, railStart, railEnd }: Eve
railStart && !railEnd && layoutCss.StreamSyslineRailStart, railStart && !railEnd && layoutCss.StreamSyslineRailStart,
railEnd && !railStart && layoutCss.StreamSyslineRailEnd railEnd && !railStart && layoutCss.StreamSyslineRailEnd
)} )}
aria-hidden
ref={railRef} ref={railRef}
/> />
<span <span
className={classNames(layoutCss.StreamDotHalo, layoutCss.StreamSyslineDotHalo)} className={classNames(layoutCss.StreamDotHalo, layoutCss.StreamSyslineDotHalo)}
aria-hidden
ref={dotRef} ref={dotRef}
> >
<span <span
@ -64,6 +63,7 @@ export function EventContent({ time, iconSrc, content, railStart, railEnd }: Eve
style={{ backgroundColor: color.Surface.OnContainer, opacity: 0.42 }} style={{ backgroundColor: color.Surface.OnContainer, opacity: 0.42 }}
/> />
</span> </span>
</span>
<Box gap="200" alignItems="Center" style={{ minWidth: 0 }} ref={bodyRef}> <Box gap="200" alignItems="Center" style={{ minWidth: 0 }} ref={bodyRef}>
<Icon style={{ opacity: 0.5, flexShrink: 0 }} size="50" src={iconSrc} /> <Icon style={{ opacity: 0.5, flexShrink: 0 }} size="50" src={iconSrc} />
<div className={layoutCss.StreamSyslineBody}>{content}</div> <div className={layoutCss.StreamSyslineBody}>{content}</div>

View file

@ -4,23 +4,28 @@ import { as } from 'folds';
import * as css from './layout.css'; import * as css from './layout.css';
import { useStreamLayoutDebug } from './streamDebug'; import { useStreamLayoutDebug } from './streamDebug';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { Time } from '../Time';
// Stream rows use a fixed `S400` gap so the rail-bridge offsets in // Stream rows use a fixed `S400` gap so the rail-bridge offsets in
// layout.css.ts (StreamRailBridgeY = S400) match the gap between rows. // layout.css.ts (StreamRailBridgeY = S400) match the gap between rows.
export const STREAM_MESSAGE_SPACING = '400' as const; export const STREAM_MESSAGE_SPACING = '400' as const;
// Sample timestamp used by the day-divider's invisible track-1 placeholder.
// Any ts works — `<Time compact />` formats with the locale's hh:mm or HH:mm,
// and tabular-nums makes every formatted timestamp the same width within a
// chat. The auto-sized grid column then matches the surrounding message rows.
const DAY_DIVIDER_PLACEHOLDER_TS = 0;
// Stream layout — DM redesign (docs/plans/dm_1x1_redesign.md §6.5b). // Stream layout — DM redesign (docs/plans/dm_1x1_redesign.md §6.5b).
// //
// Visual structure: // Visual structure (3-track CSS grid, see StreamRoot in layout.css.ts):
// ┌──┬─────────────────────────────────────────────────┐ // ┌─G─┬─time─┬─G─┬─dot─┬─G─┬───── bubble (1fr) ─────────┐
// │ │ bubble (asymmetric radius) │ //
// │● │ ┌──────────────────────────────────────────┐ │ // All three gaps come from one `--vojo-stream-gap` custom property — a
// │ │ │ time · sender · e2ee chip (header line) │ │ // single recipe variant per breakpoint. The browser auto-sizes the time
// │ │ │ message body │ │ // track to the rendered timestamp width, so 24h "16:58" and AM/PM
// │ │ └──────────────────────────────────────────┘ │ // "11:30 PM" both produce a symmetric layout with equal gaps; the dot
// └──┴─────────────────────────────────────────────────┘ // and bubble simply shift right when the format is wider.
// rail+dot bubble (time absolutely-positioned to the
// LEFT of the bubble, on the rail-time column)
export type StreamLayoutProps = { export type StreamLayoutProps = {
time?: ReactNode; time?: ReactNode;
@ -46,9 +51,8 @@ export type StreamDayDividerProps = {
export const StreamDayDivider = as<'div', StreamDayDividerProps>( export const StreamDayDivider = as<'div', StreamDayDividerProps>(
({ className, label, ...props }, ref) => { ({ className, label, ...props }, ref) => {
// Must match the `compact` value of message rows above/below — desktop // Must match the `compact` variant of message rows above/below so the
// marginLeft on the row root cascades to abs children only when both // shared `--vojo-stream-gap` resolves to the same value in both grids.
// rows resolve to the same variant.
const compact = useScreenSizeContext() === ScreenSize.Mobile; const compact = useScreenSizeContext() === ScreenSize.Mobile;
return ( return (
<div <div
@ -58,10 +62,21 @@ export const StreamDayDivider = as<'div', StreamDayDividerProps>(
aria-label={typeof label === 'string' ? label : undefined} aria-label={typeof label === 'string' ? label : undefined}
ref={ref} ref={ref}
> >
<span className={css.StreamRail} aria-hidden /> {/* Track 1: invisible <Time> placeholder so the auto-track sizes
<span className={css.StreamDayDot} aria-hidden /> to the same width as adjacent message rows keeps the rail
{/* ` label ` line emanates right from the day-marker dot; the and day-dot at a consistent X across the timeline. */}
rail-time column on the left is intentionally empty. */} <span
className={classNames(css.StreamHeaderTime, css.StreamDayTimePlaceholder)}
aria-hidden
>
<Time ts={DAY_DIVIDER_PLACEHOLDER_TS} compact />
</span>
{/* Track 2: dot column, holds the rail line + the larger day-dot. */}
<span className={css.StreamDotColumn} aria-hidden>
<span className={css.StreamRail} />
<span className={css.StreamDayDot} />
</span>
{/* Track 3: `─── label ───` line. */}
<div className={css.StreamDayLineWrap} aria-hidden> <div className={css.StreamDayLineWrap} aria-hidden>
<span className={css.StreamDayLineSegment} /> <span className={css.StreamDayLineSegment} />
<span className={css.StreamDayLabel}>{label}</span> <span className={css.StreamDayLabel}>{label}</span>
@ -120,6 +135,10 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
{...props} {...props}
ref={rootRef} ref={rootRef}
> >
<span className={css.StreamHeaderTime} ref={timeRef}>
{time}
</span>
<span className={css.StreamDotColumn} aria-hidden>
<span <span
className={classNames( className={classNames(
css.StreamRail, css.StreamRail,
@ -127,9 +146,18 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
railStart && !railEnd && css.StreamRailStart, railStart && !railEnd && css.StreamRailStart,
railEnd && !railStart && css.StreamRailEnd railEnd && !railStart && css.StreamRailEnd
)} )}
aria-hidden
ref={railRef} ref={railRef}
/> />
<span
className={classNames(css.StreamDotHalo, css.StreamHeaderDotHalo)}
ref={dotRef}
>
<span
className={css.StreamDotFill}
style={{ backgroundColor: dotColor, opacity: dotOpacity }}
/>
</span>
</span>
<div className={css.StreamColumn}> <div className={css.StreamColumn}>
<div <div
className={css.StreamBubble({ className={css.StreamBubble({
@ -139,19 +167,6 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
})} })}
ref={bubbleRef} ref={bubbleRef}
> >
<span className={css.StreamHeaderTime} ref={timeRef}>
{time}
</span>
<span
className={classNames(css.StreamDotHalo, css.StreamHeaderDotHalo)}
aria-hidden
ref={dotRef}
>
<span
className={css.StreamDotFill}
style={{ backgroundColor: dotColor, opacity: dotOpacity }}
/>
</span>
{header && ( {header && (
<div className={css.StreamBubbleHeader} ref={headerRef}> <div className={css.StreamBubbleHeader} ref={headerRef}>
{header} {header}

View file

@ -139,13 +139,47 @@ export const UsernameBold = style({
}); });
// Stream layout (DM redesign — see docs/plans/dm_1x1_redesign.md §6.5b). // Stream layout (DM redesign — see docs/plans/dm_1x1_redesign.md §6.5b).
// Desktop = 64px rail. Mobile shrinks the rail and the time-rail gap via //
// CSS-var overrides on StreamRoot/StreamDayRoot's `compact` variant so the // Symmetric three-gap layout, expressed as a 3-track CSS grid:
// timestamp text hugs the left edge of the screen. //
const StreamRailWidthDesktop = toRem(64); // ┌─G─┬─time─┬─G─┬─dot─┬─G─┬───── bubble (1fr) ─────────┐
const StreamRailWidthMobile = toRem(48); // row row
const RailWidthVar = createVar(); // left right
const StreamRailCenterX = RailWidthVar; //
// 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 StreamDotSize = toRem(9);
const StreamSyslineDotSize = toRem(6); const StreamSyslineDotSize = toRem(6);
// Slightly larger than message dots so day markers read as the visual // Slightly larger than message dots so day markers read as the visual
@ -155,76 +189,46 @@ const StreamRailLineWidth = '1px';
const StreamBubbleBorderWidth = '1px'; const StreamBubbleBorderWidth = '1px';
const StreamTimeLineHeight = toRem(13); const StreamTimeLineHeight = toRem(13);
const StreamRailBridgeY = config.space.S400; const StreamRailBridgeY = config.space.S400;
// Dot/timestamp Y must match the bubble-header text baseline:
// row paddingTop (S100) + bubble.borderTop (1px) + bubble.paddingTop (S200)
// + half of header line (T200 / 2). All three abs-positioned children land
// on the same baseline, regardless of bubble height.
const StreamMessageDotCenterY = `calc(${config.space.S100} + ${StreamBubbleBorderWidth} + ${config.space.S200} + (${config.lineHeight.T200} / 2))`;
const StreamMessageRailEndHeight = `calc(${StreamRailBridgeY} + ${StreamMessageDotCenterY})`;
const StreamRailLineLeft = `calc(${StreamRailCenterX} - (${StreamRailLineWidth} / 2))`;
const StreamDotLeft = `calc(${StreamRailCenterX} - (${StreamDotSize} / 2))`;
const StreamDayDotLeft = `calc(${StreamRailCenterX} - (${StreamDayDotSize} / 2))`;
const StreamBubbleColumnStartX = `calc(${StreamRailCenterX} + ${config.space.S500})`;
// abs-positioned children resolve against the bubble's padding box, NOT
// content box — so paddingLeft (S300) does NOT enter this calc. Earlier
// revisions added it and offset every header item 12 px to the left.
const StreamBubblePaddingBoxLeftX = `calc(${StreamBubbleColumnStartX} + ${StreamBubbleBorderWidth})`;
const StreamHeaderRailCenterX = `calc(${StreamRailCenterX} - (${StreamBubblePaddingBoxLeftX}))`;
const StreamRailGutter = config.space.S500; // Vertical centre of the bubble's header text from the row's content-area
// top. = bubble.borderTop (1) + bubble.paddingTop (S200) + line.T200 / 2.
// Distance between timestamp text-right-edge and the rail center. Mobile // Used to push the timestamp and the dot down in their grid cells so
// is tighter so "23:59" stays inside the viewport with ~4 px breathing // they read on the same baseline as the Username component inside the
// room from the screen edge. // bubble. Rail-end's height also adds `S100` back to account for the
const StreamTimeRailGapDesktop = toRem(14); // rail's negative `top` offset (it starts above the row outer edge).
const StreamTimeRailGapMobile = toRem(8); const StreamHeaderInnerCenterY = `calc(${StreamBubbleBorderWidth} + ${config.space.S200} + (${config.lineHeight.T200} / 2))`;
const TimeRailGapVar = createVar();
const StreamTimeRailGap = TimeRailGapVar;
// Width buffer for the timestamp element. Constraining the box to the
// rail-column width (~64 px) breaks `paddingRight` as a text-shift knob:
// once paddingRight pushes content-box below text width, Chromium stops
// shifting the rendered right edge. 140 px keeps content-box > text width
// at every reasonable paddingRight; element overflows LEFT into empty row
// margin.
const StreamTimeBoxWidth = toRem(140);
// Desktop nudge — shifts the whole row right so content clears PageNav.
// Applied as marginLeft on the row root; abs rail / dot children inherit
// the shift via the containing block, no per-child calc.
const StreamRowDesktopOffset = toRem(38);
// Shared by StreamRoot and StreamDayRoot — desktop default + compact-mobile
// override use the same two vars.
const StreamRowVarsDesktop = {
[RailWidthVar]: StreamRailWidthDesktop,
[TimeRailGapVar]: StreamTimeRailGapDesktop,
};
const StreamRowVarsMobile = {
[RailWidthVar]: StreamRailWidthMobile,
[TimeRailGapVar]: StreamTimeRailGapMobile,
};
export const StreamRoot = recipe({ export const StreamRoot = recipe({
base: { base: {
vars: StreamRowVarsDesktop,
position: 'relative', position: 'relative',
display: 'grid', display: 'grid',
gridTemplateColumns: `${RailWidthVar} 1fr`, // 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', alignItems: 'flex-start',
columnGap: StreamRailGutter,
paddingTop: config.space.S100, paddingTop: config.space.S100,
paddingBottom: config.space.S100, paddingBottom: config.space.S100,
paddingRight: config.space.S400, paddingRight: config.space.S400,
}, },
variants: { variants: {
compact: { compact: {
false: { marginLeft: StreamRowDesktopOffset }, false: {
// Negate MessageBase's S400/S200 horizontal padding so the row spans vars: {
// edge-to-edge — gives the timestamp text its couple-mm-from-screen- [StreamRowPadVar]: StreamRowPadDesktop,
// edge anchor and lets the bubble stretch all the way to the right. [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: { true: {
vars: StreamRowVarsMobile, vars: {
[StreamRowPadVar]: StreamRowPadMobile,
[StreamRowGapVar]: StreamRowGapMobile,
},
marginLeft: `calc(-1 * ${config.space.S400})`, marginLeft: `calc(-1 * ${config.space.S400})`,
marginRight: `calc(-1 * ${config.space.S200})`, marginRight: `calc(-1 * ${config.space.S200})`,
paddingRight: 0, paddingRight: 0,
@ -234,20 +238,11 @@ export const StreamRoot = recipe({
defaultVariants: { compact: false }, 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({ export const StreamTimeColumn = style({
textAlign: 'right',
// Right anchor at `rail.center StreamTimeRailGap`. Slightly larger than
// StreamRailGutter (the rail↔bubble gap) so the PERCEIVED text-edge↔dot
// distance matches the dot↔bubble distance — see StreamTimeRailGap comment.
paddingRight: StreamTimeRailGap,
// Width-buffer + right-anchor on the rail-time column. Grid track 1 is
// 64 px (= StreamRailWidth); element width 140 px overflows LEFT, with
// justifySelf: end pinning the element right edge to track 1's right edge
// (= rail.center). See StreamTimeBoxWidth comment for why this buffer is
// required for paddingRight to act as a working text-shift knob.
width: StreamTimeBoxWidth,
justifySelf: 'end',
paddingTop: 0,
fontSize: toRem(11), fontSize: toRem(11),
fontVariantNumeric: 'tabular-nums', fontVariantNumeric: 'tabular-nums',
color: color.Surface.OnContainer, color: color.Surface.OnContainer,
@ -264,27 +259,48 @@ globalStyle(`${StreamTimeColumn} time`, {
lineHeight: StreamTimeLineHeight, lineHeight: StreamTimeLineHeight,
}); });
// Per-row rail segment. Top/bottom extend ±S400 past the row so consecutive // DotColumn — grid track 2. Always StreamDotSize wide regardless of row
// rails join visually across MessageBase's marginTop. Background is opaque // type so message / sysline / day-divider rows all hit the same vertical
// (not alpha) so any segment overlap can't compound into darker patches. // 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({ export const StreamRail = style({
position: 'absolute', position: 'absolute',
top: `calc(-1 * ${StreamRailBridgeY})`, top: `calc(-1 * (${StreamRailBridgeY} + ${config.space.S100}))`,
bottom: `calc(-1 * ${StreamRailBridgeY})`, bottom: `calc(-1 * (${StreamRailBridgeY} + ${config.space.S100}))`,
left: StreamRailLineLeft, left: '50%',
transform: 'translateX(-50%)',
width: StreamRailLineWidth, width: StreamRailLineWidth,
background: color.Surface.ContainerLine, background: color.Surface.ContainerLine,
pointerEvents: 'none', pointerEvents: 'none',
zIndex: 0, 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({ export const StreamRailEnd = style({
bottom: 'auto', bottom: 'auto',
height: StreamMessageRailEndHeight, // 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({ export const StreamRailStart = style({
top: StreamMessageDotCenterY, top: StreamHeaderInnerCenterY,
}); });
export const StreamRailSingle = style({ export const StreamRailSingle = style({
@ -294,40 +310,65 @@ export const StreamRailSingle = style({
// Two-layer span: outer Halo paints a solid bg disk + ring that masks the // 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 // rail line behind the dot regardless of opacity; inner Fill carries the
// author colour and the read-state opacity. // 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({ export const StreamDotHalo = style({
position: 'absolute', display: 'block',
width: StreamDotSize, width: StreamDotSize,
height: StreamDotSize, height: StreamDotSize,
borderRadius: '50%', borderRadius: '50%',
left: StreamDotLeft,
background: color.Surface.Container, background: color.Surface.Container,
boxShadow: `0 0 0 3px ${color.Surface.Container}`, boxShadow: `0 0 0 3px ${color.Surface.Container}`,
pointerEvents: 'none', pointerEvents: 'none',
flexShrink: 0, position: 'relative',
zIndex: 2, 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({ export const StreamSyslineDotHalo = style({
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: StreamSyslineDotSize, width: StreamSyslineDotSize,
height: StreamSyslineDotSize, height: StreamSyslineDotSize,
left: `calc(${StreamRailCenterX} - (${StreamSyslineDotSize} / 2))`,
top: '50%',
transform: 'translateY(-50%)',
}); });
// 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({ export const StreamSyslineRailEnd = style({
bottom: 'auto', bottom: 'auto',
height: `calc(${StreamRailBridgeY} + 50%)`, height: `calc(${StreamRailBridgeY} + ${config.space.S100} + 50%)`,
}); });
export const StreamSyslineRailStart = style({ export const StreamSyslineRailStart = style({
top: '50%', 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({ export const StreamHeaderDotHalo = style({
top: `calc(${config.space.S200} + (${config.lineHeight.T200} / 2))`, marginTop: `calc(${StreamHeaderInnerCenterY} - (${StreamDotSize} / 2))`,
left: `calc(${StreamHeaderRailCenterX} - (${StreamDotSize} / 2))`,
transform: 'translateY(-50%)',
}); });
export const StreamDotFill = style({ export const StreamDotFill = style({
@ -339,12 +380,12 @@ export const StreamDotFill = style({
transition: 'opacity 220ms ease, filter 220ms ease', transition: 'opacity 220ms ease, filter 220ms ease',
}); });
// Wrapper for grid column 2 — holds the bubble and the reactions row stacked. // 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 // `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 // size when the grid track is narrower than the bubble (otherwise they'd
// overflow). // overflow).
export const StreamColumn = style({ export const StreamColumn = style({
gridColumn: 2, gridColumn: 3,
minWidth: 0, minWidth: 0,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@ -441,23 +482,11 @@ export const StreamBubbleHeader = style({
flexWrap: 'nowrap', 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({ export const StreamHeaderTime = style({
position: 'absolute', paddingTop: `calc(${StreamHeaderInnerCenterY} - (${StreamTimeLineHeight} / 2))`,
// Same vertical anchor as StreamHeaderDotHalo — time, dot, nickname
// share one baseline.
top: `calc(${config.space.S200} + (${config.lineHeight.T200} / 2))`,
// Element right edge anchored at rail.center; element overflows LEFT
// into the empty row margin. The visible rail-time column is the right
// ~64px slice of this 140px box.
left: `calc(${StreamHeaderRailCenterX} - ${StreamTimeBoxWidth})`,
width: StreamTimeBoxWidth,
// Time-side gap is StreamTimeRailGap, slightly wider than StreamRailGutter
// (the bubble-side gap) so the perceived spacing reads equal — see
// StreamTimeRailGap comment.
paddingRight: StreamTimeRailGap,
transform: 'translateY(-50%)',
boxSizing: 'border-box',
textAlign: 'right',
fontSize: toRem(11), fontSize: toRem(11),
lineHeight: StreamTimeLineHeight, lineHeight: StreamTimeLineHeight,
fontVariantNumeric: 'tabular-nums', fontVariantNumeric: 'tabular-nums',
@ -475,17 +504,17 @@ globalStyle(`${StreamHeaderTime} time`, {
lineHeight: StreamTimeLineHeight, lineHeight: StreamTimeLineHeight,
}); });
// Sysline — thin one-line state-event row inside Stream layout. Same rail // Sysline — thin single-line state-event row inside Stream layout.
// geometry as message rows so dots align, but no bubble: just iconSrc + body // Composes with StreamRoot so the time / dot / content tracks line up
// in faint mono so membership / topic / pin events read as system breadcrumbs. // 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({ export const StreamSysline = style({
alignItems: 'center',
paddingTop: toRem(2), paddingTop: toRem(2),
paddingBottom: toRem(2), paddingBottom: toRem(2),
}); });
export const StreamSyslineTimeColumn = style({
alignSelf: 'center',
});
export const StreamSyslineBody = style({ export const StreamSyslineBody = style({
fontSize: toRem(11.5), fontSize: toRem(11.5),
@ -499,22 +528,36 @@ export const StreamSyslineBody = style({
minWidth: 0, minWidth: 0,
}); });
// Day divider row — paints its own rail segment so the rail flows // Day divider row — uses the SAME 3-track grid as message rows so its
// continuously through the day boundary. All children are abs-positioned // dot and rail land on the exact same X as the dots/rails above and below
// against this root, no grid items. // 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({ export const StreamDayRoot = recipe({
base: { base: {
vars: StreamRowVarsDesktop, position: 'relative',
display: 'grid',
gridTemplateColumns: 'auto auto 1fr',
columnGap: StreamRowGapVar,
paddingLeft: StreamRowPadVar,
alignItems: 'center',
paddingTop: toRem(10), paddingTop: toRem(10),
paddingBottom: toRem(10), paddingBottom: toRem(10),
paddingRight: config.space.S400, paddingRight: config.space.S400,
position: 'relative',
}, },
variants: { variants: {
compact: { compact: {
false: { marginLeft: StreamRowDesktopOffset }, false: {
vars: {
[StreamRowPadVar]: StreamRowPadDesktop,
[StreamRowGapVar]: StreamRowGapDesktop,
},
},
true: { true: {
vars: StreamRowVarsMobile, vars: {
[StreamRowPadVar]: StreamRowPadMobile,
[StreamRowGapVar]: StreamRowGapMobile,
},
marginLeft: `calc(-1 * ${config.space.S400})`, marginLeft: `calc(-1 * ${config.space.S400})`,
marginRight: `calc(-1 * ${config.space.S200})`, marginRight: `calc(-1 * ${config.space.S200})`,
paddingRight: 0, paddingRight: 0,
@ -524,6 +567,17 @@ export const StreamDayRoot = recipe({
defaultVariants: { compact: false }, 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({ export const StreamDayLabel = style({
fontSize: toRem(11), fontSize: toRem(11),
textTransform: 'uppercase', textTransform: 'uppercase',
@ -537,33 +591,32 @@ export const StreamDayLabel = style({
'"JetBrains Mono Variable", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', '"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({ export const StreamDayDot = style({
position: 'absolute', position: 'absolute',
top: '50%', top: '50%',
left: StreamDayDotLeft, left: '50%',
transform: 'translate(-50%, -50%)',
width: StreamDayDotSize, width: StreamDayDotSize,
height: StreamDayDotSize, height: StreamDayDotSize,
borderRadius: '50%', borderRadius: '50%',
background: color.Primary.MainHover, background: color.Primary.MainHover,
boxShadow: `0 0 0 4px ${color.Surface.Container}`, boxShadow: `0 0 0 4px ${color.Surface.Container}`,
pointerEvents: 'none', pointerEvents: 'none',
transform: 'translateY(-50%)',
zIndex: 2, zIndex: 2,
}); });
// `─── Сегодня ───` line — flex row with two flex:1 hairlines and the // `─── Сегодня ───` line — grid item in track 3. Flex row with two flex:1
// label centered between them. Starts past the dot's right edge so the dot // hairlines and the label centered between them. The dot's overflow into
// reads as the anchor. // the columnGap means the leftmost line segment naturally starts past
// the dot's right edge.
export const StreamDayLineWrap = style({ export const StreamDayLineWrap = style({
position: 'absolute',
top: '50%',
left: `calc(${StreamRailCenterX} + (${StreamDayDotSize} / 2) + ${toRem(4)})`,
right: config.space.S400,
transform: 'translateY(-50%)',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: config.space.S300, gap: config.space.S300,
zIndex: 0,
}); });
export const StreamDayLineSegment = style({ export const StreamDayLineSegment = style({