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
997375b307
commit
d58e69d49f
3 changed files with 265 additions and 197 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue