style(message): tighten channel layout and channels list for mobile touch and rework the jump-to-message highlight

This commit is contained in:
heaven 2026-06-15 02:57:32 +03:00
parent 67a275561d
commit 53515ea427
4 changed files with 114 additions and 30 deletions

View file

@ -1,24 +1,39 @@
import { globalStyle, style } from '@vanilla-extract/css';
import { createVar, globalStyle, style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
// 40px circular avatar — Discord's cozy-mode avatar size. Consumers override
// the folds preset via inline style; the shared `CHANNEL_AVATAR_PX` constant
// keeps the CSS slot width and the inline override in sync.
export const CHANNEL_AVATAR_PX = 40;
const ChannelAvatarWidth = toRem(CHANNEL_AVATAR_PX);
// Avatar diameter — Discord's cozy-mode 40px on desktop, compacted to 32px
// on narrow/native viewports (the 40px slot + 16px gutters wasted ~a third
// of a 360px screen on chrome — user request, redesign item 11). Exposed as
// a CSS var scoped on ChannelRow so the inline `<Avatar>` size override in
// Channel.tsx tracks the same breakpoint as the slot width without JS
// media-query plumbing.
export const CHANNEL_AVATAR_SIZE_VAR = createVar();
const ChannelAvatarWidth = CHANNEL_AVATAR_SIZE_VAR;
// Discord cozy-mode geometry: avatar 16px from the list edge, 16px gap to the
// content, so the message column starts at 16 + 40 + 16 = 72px.
// Mirrors BubbleTimelineBand's desktop breakpoint (RoomTimeline.css.ts) —
// keep the two media queries in sync.
const MOBILE_MEDIA = 'screen and (max-width: 599px)';
// Discord cozy-mode geometry on desktop: avatar 16px from the list edge,
// 16px gap to the content, so the message column starts at 16 + 40 + 16 =
// 72px. On mobile everything tightens: 6px edge, 32px avatar, 10px gap →
// body column at 48px from the band edge (60px from the screen edge with
// the band's 12px native gutter), vs the old 84px.
const ChannelEdgePad = toRem(16);
const ChannelAvatarGap = toRem(16);
const ChannelEdgePadMobile = toRem(6);
const ChannelAvatarGapMobile = toRem(10);
export const ChannelRow = style({
display: 'flex',
alignItems: 'flex-start',
gap: ChannelAvatarGap,
vars: {
[CHANNEL_AVATAR_SIZE_VAR]: toRem(40),
},
// Span the full message-column width so the hover highlight runs edge-to-edge
// like Discord: cancel MessageBase's S400/S200 horizontal padding with negative
// margins, then re-add the 16px avatar gutter as paddingLeft (so the avatar's
// margins, then re-add the avatar gutter as paddingLeft (so the avatar's
// left edge lands 16px from the column edge — Discord cozy). NB: the column is
// the centred BubbleTimelineBand, so that edge is the band's content edge, not
// the screen edge (the band adds 12px native / 40px desktop outside this).
@ -31,9 +46,9 @@ export const ChannelRow = style({
paddingTop: toRem(2),
paddingBottom: toRem(2),
minWidth: 0,
// Hover bg subtle so adjacent rows still read as distinct units. `@media
// (hover: hover)` keeps this inert on touch where there's no pointer.
'@media': {
// Hover bg subtle so adjacent rows still read as distinct units. `@media
// (hover: hover)` keeps this inert on touch where there's no pointer.
'(hover: hover) and (pointer: fine)': {
selectors: {
'&:hover': {
@ -41,12 +56,20 @@ export const ChannelRow = style({
},
},
},
[MOBILE_MEDIA]: {
vars: {
[CHANNEL_AVATAR_SIZE_VAR]: toRem(32),
},
gap: ChannelAvatarGapMobile,
paddingLeft: ChannelEdgePadMobile,
paddingRight: toRem(8),
},
},
});
// Fixed-width slot keeps the body column aligned across collapsed rows
// (where `avatar` is `undefined` — the slot still occupies `ChannelAvatarWidth`,
// so the body's left edge stays put).
// (where `avatar` is `undefined` — the slot still occupies the avatar
// diameter, so the body's left edge stays put).
export const ChannelAvatarSlot = style({
width: ChannelAvatarWidth,
flexShrink: 0,
@ -90,14 +113,23 @@ export const ChannelThreadSummary = style({
// continuous.
export const ChannelSysline = style({
// Indent past the avatar gutter so the sysline body aligns with the message
// body column (72px). The sysline sits inside MessageBase's S400 (16px) left
// pad (it has no edge-to-edge negative margin), so paddingLeft = avatar (40)
// + gap (16) = 56 lands the content at 16 + 56 = 72px.
paddingLeft: `calc(${ChannelAvatarWidth} + ${ChannelAvatarGap})`,
// body column (72px desktop). The sysline sits inside MessageBase's S400
// (16px) left pad (it has no edge-to-edge negative margin), so paddingLeft
// = avatar (40) + gap (16) = 56 lands the content at 16 + 56 = 72px.
// ChannelRow's avatar-size var is out of scope here (sysline rows render
// standalone), so the mobile compaction repeats the literals: 32 + 10 = 42,
// minus the 10px edge-pad difference (16 6) the message rows gained,
// = 32px — keeps the sysline body on the message-body column line.
paddingLeft: `calc(${toRem(40)} + ${ChannelAvatarGap})`,
paddingRight: config.space.S200,
paddingTop: config.space.S100,
paddingBottom: config.space.S100,
color: color.SurfaceVariant.OnContainer,
'@media': {
[MOBILE_MEDIA]: {
paddingLeft: toRem(32),
},
},
});
export const ChannelSyslineIcon = style({

View file

@ -4,7 +4,7 @@ import { Avatar, Box, Icon, Icons, type IconSrc, as } from 'folds';
import { type Room } from 'matrix-js-sdk';
import * as css from './Channel.css';
import { CHANNEL_AVATAR_PX } from './Channel.css';
import { CHANNEL_AVATAR_SIZE_VAR } from './Channel.css';
import { UserAvatar } from '../../user-avatar';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
@ -137,7 +137,16 @@ export function ChannelMessageAvatar({
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
return (
<Avatar size="300" style={{ width: CHANNEL_AVATAR_PX, height: CHANNEL_AVATAR_PX }}>
// Size from the ChannelRow-scoped CSS var (40px desktop / 32px mobile) so
// the avatar tracks the slot's breakpoint while still out-ranking the
// folds size preset via inline style.
<Avatar
size="300"
style={{
width: `var(${CHANNEL_AVATAR_SIZE_VAR})`,
height: `var(${CHANNEL_AVATAR_SIZE_VAR})`,
}}
>
<UserAvatar
userId={senderId}
src={avatarUrl}

View file

@ -1,6 +1,7 @@
import { createVar, globalStyle, keyframes, style, styleVariants } from '@vanilla-extract/css';
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds';
import { VOJO_HORSESHOE_GAP_PX } from '../../../styles/horseshoe';
const SpacingVar = createVar();
const SpacingVariant = styleVariants({
@ -53,10 +54,36 @@ const highlightAnime = keyframes({
backgroundColor: color.Primary.Container,
},
});
// Jump-to-message highlight — a clean FULL-WIDTH band: flat rectangle
// (overrides the base's rounded-right radius, which read as «square left /
// rounded right» — wrong shape for a row spotlight) that bleeds through the
// timeline band's horizontal gutters out to the screen edge on native and to
// the centred band's edges on web. The bleed is transparent borders +
// cancelling negative margins: border and margin sum to zero per side, so
// the row's content layout doesn't shift when the highlight engages/clears,
// while the animated background paints under the transparent borders
// (default border-box clip). Gutter widths mirror BubbleTimelineBand
// (RoomTimeline.css.ts): VOJO_HORSESHOE_GAP_PX native, 40px ≥600px — keep
// in sync with that media query.
const HighlightGutterMobile = toRem(VOJO_HORSESHOE_GAP_PX);
const HighlightGutterDesktop = toRem(40);
const HighlightVariant = styleVariants({
true: {
animation: `${highlightAnime} 2000ms ease-in-out`,
animationIterationCount: 'infinite',
borderRadius: 0,
borderLeft: `${HighlightGutterMobile} solid transparent`,
borderRight: `${HighlightGutterMobile} solid transparent`,
marginLeft: `calc(-1 * ${HighlightGutterMobile})`,
marginRight: `calc(-1 * ${HighlightGutterMobile})`,
'@media': {
'screen and (min-width: 600px)': {
borderLeft: `${HighlightGutterDesktop} solid transparent`,
borderRight: `${HighlightGutterDesktop} solid transparent`,
marginLeft: `calc(-1 * ${HighlightGutterDesktop})`,
marginRight: `calc(-1 * ${HighlightGutterDesktop})`,
},
},
},
});

View file

@ -1,5 +1,5 @@
import { recipe } from '@vanilla-extract/recipes';
import { style } from '@vanilla-extract/css';
import { globalStyle, style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
// Category headers in the channels pane, Dawn-canon take two: the list rows
@ -7,6 +7,10 @@ import { color, config, toRem } from 'folds';
// pill-shaped tabular badges — the headers borrow exactly that vocabulary
// (icon chip + regular app font + count pill + rotating chevron) instead of
// the folds Chip pill. Typography stays the app default on purpose.
//
// Generous vertical padding (8px on top of the 26px icon ≈ 42px row) keeps
// the collapse target comfortably tappable on touch — the previous 4px row
// read as a thin label, not a button.
export const SectionHeader = style({
appearance: 'none',
WebkitAppearance: 'none',
@ -15,24 +19,36 @@ export const SectionHeader = style({
width: '100%',
display: 'flex',
alignItems: 'center',
gap: toRem(8),
padding: `${toRem(4)} ${toRem(8)}`,
borderRadius: toRem(8),
gap: toRem(10),
padding: `${toRem(8)} ${toRem(8)}`,
borderRadius: toRem(10),
cursor: 'pointer',
font: 'inherit',
color: color.Background.OnContainer,
transition: 'background-color 120ms ease-out',
selectors: {
'&:hover': {
// Touch press feedback — `:active` clears on touchend, so it can't
// stick the way the WebView's synthesised `:hover` does.
'&:active': {
backgroundColor: color.Background.ContainerHover,
},
'&:focus-visible': {
outline: `2px solid ${color.Primary.Main}`,
outlineOffset: toRem(-2),
},
},
});
// Mouse-only hover + focus ring. Android WebView synthesises sticky
// `:hover` AND `:focus-visible` on the tapped element and never clears
// them until the next interaction elsewhere — ungated rules here left the
// section header recoloured/outlined after every tap. Same `data-input`
// gate as JumpToLatestFab / ChatComposer (matchMedia interaction queries
// lie on Android WebView — see the input-mode detector in src/index.tsx).
globalStyle(`:root[data-input="mouse"] ${SectionHeader}:hover`, {
backgroundColor: color.Background.ContainerHover,
});
globalStyle(`:root[data-input="mouse"] ${SectionHeader}:focus-visible`, {
outline: `2px solid ${color.Primary.Main}`,
outlineOffset: toRem(-2),
});
// Tinted square glyph — same idiom as the mock's letter-avatars in the bots
// sidebar (rounded square, accent-on-tint). One accent per section kind.
export const SectionIcon = recipe({
@ -57,8 +73,8 @@ export const SectionIcon = recipe({
});
export const SectionLabel = style({
fontSize: toRem(13.5),
lineHeight: toRem(18),
fontSize: toRem(14),
lineHeight: toRem(19),
fontWeight: 600,
whiteSpace: 'nowrap',
overflow: 'hidden',