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:
parent
f4d1fdcebc
commit
c74029c38c
3 changed files with 265 additions and 197 deletions
|
|
@ -33,36 +33,36 @@ export function EventContent({ time, iconSrc, content, railStart, railEnd }: Eve
|
|||
);
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
className={classNames(layoutCss.StreamRoot({ compact }), layoutCss.StreamSysline)}
|
||||
ref={rootRef}
|
||||
>
|
||||
<div
|
||||
className={classNames(layoutCss.StreamTimeColumn, layoutCss.StreamSyslineTimeColumn)}
|
||||
ref={timeRef}
|
||||
>
|
||||
<div className={layoutCss.StreamTimeColumn} ref={timeRef}>
|
||||
{time}
|
||||
</div>
|
||||
<span
|
||||
className={classNames(
|
||||
layoutCss.StreamRail,
|
||||
railStart && railEnd && layoutCss.StreamRailSingle,
|
||||
railStart && !railEnd && layoutCss.StreamSyslineRailStart,
|
||||
railEnd && !railStart && layoutCss.StreamSyslineRailEnd
|
||||
)}
|
||||
aria-hidden
|
||||
ref={railRef}
|
||||
/>
|
||||
<span
|
||||
className={classNames(layoutCss.StreamDotHalo, layoutCss.StreamSyslineDotHalo)}
|
||||
aria-hidden
|
||||
ref={dotRef}
|
||||
>
|
||||
<span className={layoutCss.StreamDotColumn} aria-hidden>
|
||||
<span
|
||||
className={layoutCss.StreamDotFill}
|
||||
style={{ backgroundColor: color.Surface.OnContainer, opacity: 0.42 }}
|
||||
className={classNames(
|
||||
layoutCss.StreamRail,
|
||||
railStart && railEnd && layoutCss.StreamRailSingle,
|
||||
railStart && !railEnd && layoutCss.StreamSyslineRailStart,
|
||||
railEnd && !railStart && layoutCss.StreamSyslineRailEnd
|
||||
)}
|
||||
ref={railRef}
|
||||
/>
|
||||
<span
|
||||
className={classNames(layoutCss.StreamDotHalo, layoutCss.StreamSyslineDotHalo)}
|
||||
ref={dotRef}
|
||||
>
|
||||
<span
|
||||
className={layoutCss.StreamDotFill}
|
||||
style={{ backgroundColor: color.Surface.OnContainer, opacity: 0.42 }}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<Box gap="200" alignItems="Center" style={{ minWidth: 0 }} ref={bodyRef}>
|
||||
<Icon style={{ opacity: 0.5, flexShrink: 0 }} size="50" src={iconSrc} />
|
||||
|
|
|
|||
|
|
@ -4,23 +4,28 @@ import { as } from 'folds';
|
|||
import * as css from './layout.css';
|
||||
import { useStreamLayoutDebug } from './streamDebug';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||
import { Time } from '../Time';
|
||||
|
||||
// Stream rows use a fixed `S400` gap so the rail-bridge offsets in
|
||||
// layout.css.ts (StreamRailBridgeY = S400) match the gap between rows.
|
||||
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).
|
||||
//
|
||||
// Visual structure:
|
||||
// ┌──┬─────────────────────────────────────────────────┐
|
||||
// │ │ bubble (asymmetric radius) │
|
||||
// │● │ ┌──────────────────────────────────────────┐ │
|
||||
// │ │ │ time · sender · e2ee chip (header line) │ │
|
||||
// │ │ │ message body │ │
|
||||
// │ │ └──────────────────────────────────────────┘ │
|
||||
// └──┴─────────────────────────────────────────────────┘
|
||||
// rail+dot bubble (time absolutely-positioned to the
|
||||
// LEFT of the bubble, on the rail-time column)
|
||||
// Visual structure (3-track CSS grid, see StreamRoot in layout.css.ts):
|
||||
// ┌─G─┬─time─┬─G─┬─dot─┬─G─┬───── bubble (1fr) ─────────┐
|
||||
//
|
||||
// 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.
|
||||
|
||||
export type StreamLayoutProps = {
|
||||
time?: ReactNode;
|
||||
|
|
@ -46,9 +51,8 @@ export type StreamDayDividerProps = {
|
|||
|
||||
export const StreamDayDivider = as<'div', StreamDayDividerProps>(
|
||||
({ className, label, ...props }, ref) => {
|
||||
// Must match the `compact` value of message rows above/below — desktop
|
||||
// marginLeft on the row root cascades to abs children only when both
|
||||
// rows resolve to the same variant.
|
||||
// Must match the `compact` variant of message rows above/below so the
|
||||
// shared `--vojo-stream-gap` resolves to the same value in both grids.
|
||||
const compact = useScreenSizeContext() === ScreenSize.Mobile;
|
||||
return (
|
||||
<div
|
||||
|
|
@ -58,10 +62,21 @@ export const StreamDayDivider = as<'div', StreamDayDividerProps>(
|
|||
aria-label={typeof label === 'string' ? label : undefined}
|
||||
ref={ref}
|
||||
>
|
||||
<span className={css.StreamRail} aria-hidden />
|
||||
<span className={css.StreamDayDot} aria-hidden />
|
||||
{/* `─── label ───` line emanates right from the day-marker dot; the
|
||||
rail-time column on the left is intentionally empty. */}
|
||||
{/* 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. */}
|
||||
<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>
|
||||
<span className={css.StreamDayLineSegment} />
|
||||
<span className={css.StreamDayLabel}>{label}</span>
|
||||
|
|
@ -120,16 +135,29 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
|
|||
{...props}
|
||||
ref={rootRef}
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
css.StreamRail,
|
||||
railStart && railEnd && css.StreamRailSingle,
|
||||
railStart && !railEnd && css.StreamRailStart,
|
||||
railEnd && !railStart && css.StreamRailEnd
|
||||
)}
|
||||
aria-hidden
|
||||
ref={railRef}
|
||||
/>
|
||||
<span className={css.StreamHeaderTime} ref={timeRef}>
|
||||
{time}
|
||||
</span>
|
||||
<span className={css.StreamDotColumn} aria-hidden>
|
||||
<span
|
||||
className={classNames(
|
||||
css.StreamRail,
|
||||
railStart && railEnd && css.StreamRailSingle,
|
||||
railStart && !railEnd && css.StreamRailStart,
|
||||
railEnd && !railStart && css.StreamRailEnd
|
||||
)}
|
||||
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.StreamBubble({
|
||||
|
|
@ -139,19 +167,6 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
|
|||
})}
|
||||
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 && (
|
||||
<div className={css.StreamBubbleHeader} ref={headerRef}>
|
||||
{header}
|
||||
|
|
|
|||
|
|
@ -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 `<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({
|
||||
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 `<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: {
|
||||
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({
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue