(
})}
ref={bubbleRef}
>
-
- {time}
-
-
-
-
{header && (
{header}
diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts
index 13295339..8001899d 100644
--- a/src/app/components/message/layout/layout.css.ts
+++ b/src/app/components/message/layout/layout.css.ts
@@ -139,13 +139,47 @@ export const UsernameBold = style({
});
// 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
-// timestamp text hugs the left edge of the screen.
-const StreamRailWidthDesktop = toRem(64);
-const StreamRailWidthMobile = toRem(48);
-const RailWidthVar = createVar();
-const StreamRailCenterX = RailWidthVar;
+//
+// 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
@@ -155,76 +189,46 @@ const StreamRailLineWidth = '1px';
const StreamBubbleBorderWidth = '1px';
const StreamTimeLineHeight = toRem(13);
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;
-
-// Distance between timestamp text-right-edge and the rail center. Mobile
-// is tighter so "23:59" stays inside the viewport with ~4 px breathing
-// room from the screen edge.
-const StreamTimeRailGapDesktop = toRem(14);
-const StreamTimeRailGapMobile = toRem(8);
-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,
-};
+// 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: {
- vars: StreamRowVarsDesktop,
position: 'relative',
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',
- columnGap: StreamRailGutter,
paddingTop: config.space.S100,
paddingBottom: config.space.S100,
paddingRight: config.space.S400,
},
variants: {
compact: {
- false: { marginLeft: StreamRowDesktopOffset },
- // Negate MessageBase's S400/S200 horizontal padding so the row spans
- // edge-to-edge — gives the timestamp text its couple-mm-from-screen-
- // edge anchor and lets the bubble stretch all the way to the right.
+ 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: StreamRowVarsMobile,
+ vars: {
+ [StreamRowPadVar]: StreamRowPadMobile,
+ [StreamRowGapVar]: StreamRowGapMobile,
+ },
marginLeft: `calc(-1 * ${config.space.S400})`,
marginRight: `calc(-1 * ${config.space.S200})`,
paddingRight: 0,
@@ -234,20 +238,11 @@ export const StreamRoot = recipe({
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({
- 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),
fontVariantNumeric: 'tabular-nums',
color: color.Surface.OnContainer,
@@ -264,27 +259,48 @@ globalStyle(`${StreamTimeColumn} time`, {
lineHeight: StreamTimeLineHeight,
});
-// Per-row rail segment. Top/bottom extend ±S400 past the row so consecutive
-// rails join visually across MessageBase's marginTop. Background is opaque
-// (not alpha) so any segment overlap can't compound into darker patches.
+// 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})`,
- bottom: `calc(-1 * ${StreamRailBridgeY})`,
- left: StreamRailLineLeft,
+ 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',
- 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({
- top: StreamMessageDotCenterY,
+ top: StreamHeaderInnerCenterY,
});
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
// 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({
- position: 'absolute',
+ display: 'block',
width: StreamDotSize,
height: StreamDotSize,
borderRadius: '50%',
- left: StreamDotLeft,
background: color.Surface.Container,
boxShadow: `0 0 0 3px ${color.Surface.Container}`,
pointerEvents: 'none',
- flexShrink: 0,
+ 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,
- 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({
bottom: 'auto',
- height: `calc(${StreamRailBridgeY} + 50%)`,
+ 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({
- top: `calc(${config.space.S200} + (${config.lineHeight.T200} / 2))`,
- left: `calc(${StreamHeaderRailCenterX} - (${StreamDotSize} / 2))`,
- transform: 'translateY(-50%)',
+ marginTop: `calc(${StreamHeaderInnerCenterY} - (${StreamDotSize} / 2))`,
});
export const StreamDotFill = style({
@@ -339,12 +380,12 @@ export const StreamDotFill = style({
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
// size when the grid track is narrower than the bubble (otherwise they'd
// overflow).
export const StreamColumn = style({
- gridColumn: 2,
+ gridColumn: 3,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
@@ -441,23 +482,11 @@ export const StreamBubbleHeader = style({
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({
- position: 'absolute',
- // 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',
+ paddingTop: `calc(${StreamHeaderInnerCenterY} - (${StreamTimeLineHeight} / 2))`,
fontSize: toRem(11),
lineHeight: StreamTimeLineHeight,
fontVariantNumeric: 'tabular-nums',
@@ -475,17 +504,17 @@ globalStyle(`${StreamHeaderTime} time`, {
lineHeight: StreamTimeLineHeight,
});
-// Sysline — thin one-line state-event row inside Stream layout. Same rail
-// geometry as message rows so dots align, but no bubble: just iconSrc + body
-// in faint mono so membership / topic / pin events read as system breadcrumbs.
+// 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 StreamSyslineTimeColumn = style({
- alignSelf: 'center',
-});
export const StreamSyslineBody = style({
fontSize: toRem(11.5),
@@ -499,22 +528,36 @@ export const StreamSyslineBody = style({
minWidth: 0,
});
-// Day divider row — paints its own rail segment so the rail flows
-// continuously through the day boundary. All children are abs-positioned
-// against this root, no grid items.
+// 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 `` placeholder rendered by Stream.tsx so the
+// `auto` track sizes to the same width as the surrounding message rows.
export const StreamDayRoot = recipe({
base: {
- vars: StreamRowVarsDesktop,
+ position: 'relative',
+ display: 'grid',
+ gridTemplateColumns: 'auto auto 1fr',
+ columnGap: StreamRowGapVar,
+ paddingLeft: StreamRowPadVar,
+ alignItems: 'center',
paddingTop: toRem(10),
paddingBottom: toRem(10),
paddingRight: config.space.S400,
- position: 'relative',
},
variants: {
compact: {
- false: { marginLeft: StreamRowDesktopOffset },
+ false: {
+ vars: {
+ [StreamRowPadVar]: StreamRowPadDesktop,
+ [StreamRowGapVar]: StreamRowGapDesktop,
+ },
+ },
true: {
- vars: StreamRowVarsMobile,
+ vars: {
+ [StreamRowPadVar]: StreamRowPadMobile,
+ [StreamRowGapVar]: StreamRowGapMobile,
+ },
marginLeft: `calc(-1 * ${config.space.S400})`,
marginRight: `calc(-1 * ${config.space.S200})`,
paddingRight: 0,
@@ -524,6 +567,17 @@ export const StreamDayRoot = recipe({
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',
@@ -537,33 +591,32 @@ export const StreamDayLabel = style({
'"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: StreamDayDotLeft,
+ 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',
- transform: 'translateY(-50%)',
zIndex: 2,
});
-// `─── Сегодня ───` line — flex row with two flex:1 hairlines and the
-// label centered between them. Starts past the dot's right edge so the dot
-// reads as the anchor.
+// `─── Сегодня ───` 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({
- position: 'absolute',
- top: '50%',
- left: `calc(${StreamRailCenterX} + (${StreamDayDotSize} / 2) + ${toRem(4)})`,
- right: config.space.S400,
- transform: 'translateY(-50%)',
display: 'flex',
alignItems: 'center',
gap: config.space.S300,
- zIndex: 0,
});
export const StreamDayLineSegment = style({