style(room): lead the 1:1 stream row with the rail+dot, move the timestamp beside the nick and tint the peer nick lavender

This commit is contained in:
heaven 2026-05-30 14:35:41 +03:00
parent 109941e0dd
commit a84c534179
4 changed files with 150 additions and 158 deletions

View file

@ -27,7 +27,7 @@ export function EventContent({
}: EventContentProps) {
const compact = useScreenSizeContext() === ScreenSize.Mobile;
const rootRef = useRef<HTMLDivElement>(null);
const timeRef = useRef<HTMLDivElement>(null);
const timeRef = useRef<HTMLSpanElement>(null);
const railRef = useRef<HTMLSpanElement>(null);
const dotRef = useRef<HTMLSpanElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
@ -49,17 +49,15 @@ export function EventContent({
}
// 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.
// Same 2-track grid as message rows (StreamRoot) — track 1 dot column,
// track 2 body — so the dot's X aligns with the dots above and below it.
// The timestamp trails the body (content → time), mirroring the message
// header's nick → time order now that the dot leads the row.
return (
<div
className={classNames(layoutCss.StreamRoot({ compact }), layoutCss.StreamSysline)}
ref={rootRef}
>
<div className={layoutCss.StreamTimeColumn} ref={timeRef}>
{time}
</div>
<span className={layoutCss.StreamDotColumn} aria-hidden>
<span
className={classNames(
@ -83,6 +81,11 @@ export function EventContent({
<Box gap="200" alignItems="Center" style={{ minWidth: 0 }} ref={bodyRef}>
<Icon style={{ opacity: 0.5, flexShrink: 0 }} size="50" src={iconSrc} />
<div className={layoutCss.StreamSyslineBody}>{content}</div>
{time && (
<span className={layoutCss.StreamSyslineTime} ref={timeRef}>
{time}
</span>
)}
</Box>
</div>
);

View file

@ -4,7 +4,6 @@ import { as, toRem } from 'folds';
import * as css from './layout.css';
import { useStreamLayoutDebug } from './streamDebug';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { Time } from '../Time';
// Day-divider rows fall back to this `S400` MessageBase spacing variant
// (see RoomTimeline.renderDayDivider). Message rows force collapse=true
@ -13,12 +12,6 @@ import { Time } from '../Time';
// over the tighter gap stays hidden by the dot halo.
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;
// Rail-dot diameters. The base dot is 9px (see `StreamDotSize` /
// `StreamDotColumn` in layout.css.ts, which keep the rail X consistent). The
// neutral gray dot is 0.95× that; the «state» dots (green = read, gold =
@ -32,14 +25,15 @@ const STREAM_DOT_PROMINENT = toRem(9.405);
// Stream layout — DM «VS Code chat» redesign
// (docs/plans/dm_stream_vscode_redesign.md).
//
// Visual structure (3-track CSS grid, see StreamRoot in layout.css.ts):
// ┌─G─┬─time─┬─G─┬─dot─┬─G─┬───── bubble (1fr) ─────────┐
// Visual structure (2-track CSS grid, see StreamRoot in layout.css.ts):
// ┌─G─┬─dot─┬─G─┬───── content (1fr) ───────────────────┐
// nick · time ← header line
// bubble / text
//
// All three gaps come from one `--vojo-stream-gap` custom property — a
// single recipe variant per breakpoint. The browser auto-sizes the time
// 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.
// The rail + dot lead (track 1), hugging the left screen edge; the nick
// and a muted timestamp ride the header line at the top of the content
// column (track 2), in that order — rail+dot → nick → time. The two gaps
// (screen→dot, dot→content) come from the StreamRoot recipe per breakpoint.
export type StreamLayoutProps = {
time?: ReactNode;
@ -53,15 +47,16 @@ export type StreamLayoutProps = {
isOwn?: boolean;
compact?: boolean;
// Same-sender continuation row (the whole run after the first message, any
// minute): drop the rail dot + timestamp + nick and stack the body tight
// under the previous one. The timestamp is kept in the DOM (invisible) only
// to reserve the time-track width. The caller also passes `header={undefined}`
// for collapsed rows. See RoomTimeline `collapsed`.
// minute): drop the rail dot and the whole header line (nick + timestamp)
// and stack the body tight under the previous one. The caller also passes
// `header={undefined}` for collapsed rows — and since the time now rides the
// header line rather than its own grid track, dropping the header drops the
// time with it (no width reservation needed). See RoomTimeline `collapsed`.
collapsed?: boolean;
// Author name — rendered as a bold label ABOVE the bubble, on the
// dot/timestamp baseline (DM «VS Code chat» redesign). `undefined` for
// media rows (the name is overlaid on the media instead) and for collapsed
// continuation rows.
// Author name — rendered as a bold label ABOVE the bubble, leading the
// header line (nick → time) on the dot baseline (DM «VS Code chat»
// redesign). `undefined` for collapsed continuation rows. Media rows still
// show the nick here too (above the media, no overlay).
header?: ReactNode;
railStart?: boolean;
railEnd?: boolean;
@ -100,21 +95,14 @@ export const StreamDayDivider = as<'div', StreamDayDividerProps>(
aria-label={typeof label === 'string' ? label : undefined}
ref={ref}
>
{/* Track 1: invisible <Time> placeholder so the auto-track sizes
to the same width as adjacent message rows keeps the rail
and day-dot at a consistent X across the timeline. */}
<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. */}
{/* Track 1: dot column, holds the rail line + the larger day-dot.
Same fixed width + paddingLeft anchor as message rows, so the
day-dot lands on the exact same X as the message dots. */}
<span className={css.StreamDotColumn} aria-hidden>
<span className={css.StreamRail} />
<span className={css.StreamDayDot} />
</span>
{/* Track 3: `─── label ───` line. */}
{/* Track 2: `─── label ───` line. */}
<div className={css.StreamDayLineWrap} aria-hidden>
<span className={css.StreamDayLineSegment} />
<span className={css.StreamDayLabel}>{label}</span>
@ -180,16 +168,7 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
{...props}
ref={rootRef}
>
{/* Collapsed rows keep the timestamp in the DOM (so the auto-sized
time track stays the same width and the body column doesn't shift)
but hide it only the first message of the minute shows its time. */}
<span
className={classNames(css.StreamHeaderTime, collapsed && css.StreamHeaderTimeHidden)}
aria-hidden={collapsed || undefined}
ref={timeRef}
>
{time}
</span>
{/* Track 1: rail + dot, leading the row at the left screen edge. */}
<span className={css.StreamDotColumn} aria-hidden>
{/* The rail is suppressed entirely on trailing continuation rows
(after the last dot) so the line stops at the last dot instead of
@ -223,10 +202,23 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
</span>
)}
</span>
{/* Track 2: header line (nick → muted time) + bubble + reactions. */}
<div className={css.StreamColumn}>
{/* The header prints once at the head of a same-sender run; every
continuation row drops it (header is undefined when collapsed), so
the nick + timestamp only show on the first message of a run. The
nick colour is per-side: own = white/black, peer (vojo) = lavender. */}
{header && (
<div className={css.StreamName} ref={headerRef}>
<div
className={classNames(css.StreamName, isOwn ? css.StreamNameOwn : css.StreamNamePeer)}
ref={headerRef}
>
{header}
{time && (
<span className={css.StreamHeaderTime} ref={timeRef}>
{time}
</span>
)}
</div>
)}
<div

View file

@ -141,42 +141,34 @@ export const UsernameBold = style({
// Stream layout (DM «VS Code chat» redesign — see
// docs/plans/dm_stream_vscode_redesign.md).
//
// Symmetric three-gap layout, expressed as a 3-track CSS grid:
// Rail-first layout, expressed as a 2-track CSS grid (the rail + dot hug
// the screen edge; the nick and timestamp share the header line):
//
// ┌─G─┬─time─┬─G─┬─dot─┬─G─┬───── bubble (1fr) ─────────┐
// row row
// left right
// ┌─G─┬─dot─┬─G─┬───── content (1fr) ────────────────────┐
// row │ │ nick · time ← header line row
// left rail │ bubble / text 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.
// where G = `column-gap` = `padding-left` of the row root. Track 1 is the
// dot column (sized to the dot diameter), anchored `G` off the screen edge
// so the rail reads as a margin-rail down the left side. Track 2 (`1fr`) is
// the content column: the header line (`nick` then a muted `time`) followed
// by the bubble / plain text. The timestamp is no longer its own grid track
// — it rides the header line to the right of the nick, so there's no
// locale-width auto-track to keep in sync any more.
//
// 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`)
// - StreamRowPadVar → screen→dot gap (the row's `padding-left`)
// - StreamRowGapVar → dot→content gap (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).
// Splitting these lets the dot→content gap tighten on desktop and loosen
// on mobile without disturbing the screen-edge anchor.
//
// The whole time→dot→nick block was nudged ~1mm (~4px) to the right per the
// latest request — both pad values stepped up one token.
// Mobile: pad = S200 (8px — was S100; +4px ≈ 1mm off the screen edge); gap =
// S200 (the user asked to double the inter-element gap on native).
// Desktop: pad = S500 (20px — was S400; +4px ≈ 1mm further from the PageNav,
// the column still clears the nav rail); 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).
// Mobile: pad = S200 (8px off the screen edge); gap = S200.
// Desktop: pad = S500 (20px — clears the PageNav rail); gap = S500 / 1.1 ≈
// 18.2 px (desktop dot→content gap shrunk by 1.1× to keep the layout tight
// without dropping a whole token).
const StreamRowPadVar = createVar();
const StreamRowGapVar = createVar();
const StreamRowPadMobile = config.space.S200;
@ -218,10 +210,10 @@ 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',
// Two tracks: dot column (auto = dot diameter) | content (1fr). The dot
// column leads so the rail + dot hug the left screen edge; the nick and
// timestamp ride the header line inside the content column.
gridTemplateColumns: 'auto 1fr',
columnGap: StreamRowGapVar,
paddingLeft: StreamRowPadVar,
alignItems: 'flex-start',
@ -269,28 +261,7 @@ export const StreamRoot = recipe({
defaultVariants: { compact: false, collapsed: 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
// DotColumn — grid track 1. 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
@ -411,12 +382,12 @@ export const StreamDotFill = style({
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).
// Wrapper for grid track 2 — holds the header line (nick + time), 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,
gridColumn: 2,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
@ -537,11 +508,13 @@ export const StreamBubble = recipe({
},
});
// Author name — sits ABOVE the bubble as the first child of StreamColumn
// (track 3), aligned on the dot/timestamp baseline. Bold, a step larger than
// chat body, pure white (dark) / black (light) via `--vojo-stream-name`. The
// inner Username button inherits colour/size/weight from here, so callers
// pass the name without their own colour/size.
// Header line — sits ABOVE the bubble as the first child of StreamColumn
// (track 2), aligned on the dot baseline. Holds the bold author nick followed
// by a muted timestamp (rail+dot → nick → time, per the latest request). Bold,
// a step larger than chat body; the nick colour comes from the own/peer
// modifier classes below (own = white/black, peer = lavender). The inner Username
// button inherits colour/size/weight from here; the timestamp carries its own
// explicit muted colour so it does NOT pick up the nick tint.
export const StreamName = style({
position: 'relative',
// Symmetric top-left corner (user request): the vertical gap from the nick
@ -554,34 +527,48 @@ export const StreamName = style({
lineHeight: StreamNameLineHeight,
minHeight: StreamNameLineHeight,
fontWeight: 700,
color: 'var(--vojo-stream-name)',
display: 'flex',
alignItems: 'center',
gap: toRem(6),
gap: toRem(8),
flexWrap: 'nowrap',
minWidth: 0,
// Bound the label to the message column so a long display name truncates
// (ellipsis) instead of overflowing the rail. StreamColumn uses
// (ellipsis) instead of overflowing the row. StreamColumn uses
// `align-items: flex-start`, so without this the flex container would
// content-size past the column edge.
maxWidth: '100%',
});
// Let the (single) name child shrink so css.Username's ellipsis engages on
// long display names — replaces the old `<Text truncate>` wrapper. Must be a
// globalStyle: vanilla-extract `style()` selectors may only target `&`.
// Own / peer nick colour. Applied alongside StreamName so the inner Username
// inherits the tint. Own = the original neutral white (dark) / black (light);
// peer (the vojo side) = lavender, the Dawn brand accent. Both bind to CSS
// vars (see index.css) so they stay tunable.
export const StreamNameOwn = style({
color: 'var(--vojo-stream-name-own)',
});
export const StreamNamePeer = style({
color: 'var(--vojo-stream-name-peer)',
});
// Let the nick child shrink so css.Username's ellipsis engages on long display
// names — replaces the old `<Text truncate>` wrapper. Must be a globalStyle:
// vanilla-extract `style()` selectors may only target `&`. The timestamp child
// opts out of shrinking via `flex-shrink: 0` in StreamHeaderTime below.
globalStyle(`${StreamName} > *`, {
minWidth: 0,
});
// 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.
// Header timestamp — inline on the header line, to the RIGHT of the nick
// (rail+dot → nick → time). Centred against the taller nick by the header
// row's `align-items: center`, so no manual baseline offset is needed. Carries
// its own explicit muted colour so the nick's green/lavender tint does not
// bleed onto it, and `flex-shrink: 0` so the nick truncates first.
export const StreamHeaderTime = style({
paddingTop: `calc(${StreamHeaderInnerCenterY} - (${StreamTimeLineHeight} / 2))`,
flexShrink: 0,
fontSize: toRem(11),
lineHeight: StreamTimeLineHeight,
fontVariantNumeric: 'tabular-nums',
fontWeight: 400,
color: color.Surface.OnContainer,
opacity: 0.55,
whiteSpace: 'nowrap',
@ -596,17 +583,10 @@ globalStyle(`${StreamHeaderTime} time`, {
lineHeight: StreamTimeLineHeight,
});
// Collapsed continuation rows keep the timestamp in the DOM so the auto-sized
// time track stays the same width (body column doesn't shift) but render it
// invisible — a same-sender run shows its dot+timestamp only on the first row.
export const StreamHeaderTimeHidden = style({
visibility: 'hidden',
});
// 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
// Composes with StreamRoot so the dot / content tracks line up vertically
// with message rows above and below (dot column | body). Override align-items
// to center because the sysline content is one line tall and reads best
// vertically centred.
export const StreamSysline = style({
alignItems: 'center',
@ -629,16 +609,38 @@ export const StreamSyslineBody = style({
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.
// Sysline timestamp — trailing the body on the same line (content → time),
// mirroring the message header's nick → time order now that the dot leads the
// row. Muted mono, never shrinks, so the italic body truncates first.
export const StreamSyslineTime = style({
flexShrink: 0,
fontSize: toRem(11),
lineHeight: StreamTimeLineHeight,
fontVariantNumeric: 'tabular-nums',
color: color.Surface.OnContainer,
opacity: 0.45,
whiteSpace: 'nowrap',
fontFamily:
'"JetBrains Mono Variable", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
});
globalStyle(`${StreamSyslineTime} time`, {
display: 'block',
fontFamily: 'inherit',
fontSize: toRem(11),
lineHeight: StreamTimeLineHeight,
});
// Day divider row — uses the SAME 2-track grid as message rows (dot column |
// content) 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 is the dot column (fixed dot width, anchored by the same paddingLeft),
// so the dots align by construction — no invisible time placeholder needed.
export const StreamDayRoot = recipe({
base: {
position: 'relative',
display: 'grid',
gridTemplateColumns: 'auto auto 1fr',
gridTemplateColumns: 'auto 1fr',
columnGap: StreamRowGapVar,
paddingLeft: StreamRowPadVar,
alignItems: 'center',
@ -668,17 +670,6 @@ 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',
@ -710,7 +701,7 @@ export const StreamDayDot = style({
zIndex: 2,
});
// `─── Сегодня ───` line — grid item in track 3. Flex row with two flex:1
// `─── Сегодня ───` line — grid item in track 2. 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.

View file

@ -34,14 +34,17 @@
/* DM 1-1 «VS Code chat» redesign tokens (see
docs/plans/dm_stream_vscode_redesign.md). Light-theme defaults here;
dark overrides in `.dark-theme`.
* stream-name author label colour (pure black light / pure white
dark, per spec).
* stream-name-own own author label colour (pure black light /
white dark the original neutral nick colour).
* stream-name-peer peer (vojo) author label colour (lavender, the
Dawn brand accent). Both per-side, see StreamName.
* dot-neutral gray rail dot for unread / in-flight (green = my-read,
gold = mention, red = my-failed are folds tokens in
useDotColor).
NB: the incoming-bubble fill is NOT a var `StreamBubble` binds it to
`color.Surface.Container` so it always matches the composer card. */
--vojo-stream-name: #000000;
--vojo-stream-name-own: #000000;
--vojo-stream-name-peer: #5b6aff;
--vojo-dot-neutral: #9aa0aa;
--font-emoji: 'Twemoji_DISABLED';
@ -68,8 +71,11 @@
--vojo-timeline-rail: #2a2e38;
/* DM 1-1 «VS Code chat» redesign dark palette. (Incoming-bubble fill
binds to color.Surface.Container in StreamBubble, not a var here.) */
--vojo-stream-name: #ffffff;
binds to color.Surface.Container in StreamBubble, not a var here.)
Own nick = white (original neutral), peer (vojo) nick = the #9580ff
Dawn brand lavender. */
--vojo-stream-name-own: #ffffff;
--vojo-stream-name-peer: #9580ff;
--vojo-dot-neutral: #6b7280;
--font-secondary: 'InterVariable', var(--font-emoji), sans-serif;