Room list on a phone: plain text rows in the heading type; no Create Room or Join with Address #40

Merged
void merged 1 commit from void/room-list-plain-rows into dev 2026-11-09 15:39:39 +00:00
5 changed files with 174 additions and 36 deletions

View file

@ -158,6 +158,7 @@ export const BubbleContentArrowLeft = style({
const PHONE = 'screen and (max-width: 700px)';
const PHONE_PORTRAIT = 'min(38vw, 160px)';
const PHONE_TEXT_FLOOR = toRem(170);
const PORTRAIT_OVERHANG = 58;
const PORTRAIT = toRem(124);
const POSE_GUTTER = toRem(14);
const BUBBLE_CORNER = toRem(6);
@ -180,7 +181,6 @@ export const BubbleRowPortrait = style({
'@media': {
[PHONE]: {
minHeight: 0,
marginTop: toRem(58),
},
},
});
@ -207,6 +207,12 @@ export const BubbleHeaderPose = style({
'@media': {
[PHONE]: {
alignSelf: 'flex-end',
// The band above the bubble where the portrait overhangs: the header sits at its foot, beside
// the portrait, so the overhang needs no margin of its own.
minHeight: toRem(PORTRAIT_OVERHANG),
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
margin: `0 ${toRem(14)} ${toRem(2)}`,
width: 'max-content',
maxWidth: `calc(100vw - ${PHONE_PORTRAIT} - ${toRem(64)})`,
@ -224,6 +230,15 @@ export const BubbleHeaderPoseAlt = style({
},
});
// A portrait row with no header (nothing to fill the overhang band) keeps the band as a margin.
globalStyle(`${BubbleRowPortrait}:not(:has(.${BubbleHeaderPose}))`, {
'@media': {
[PHONE]: {
marginTop: toRem(PORTRAIT_OVERHANG),
},
},
});
export const BubbleColumnPose = style({
position: 'relative',
});

View file

@ -1,4 +1,4 @@
import { ComplexStyleRule, createVar, style } from '@vanilla-extract/css';
import { ComplexStyleRule, StyleRule, createVar, globalStyle, style } from '@vanilla-extract/css';
import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
import { ContainerColor, DefaultReset, Disabled, RadiiVariant, color, config, toRem } from 'folds';
import { surfaceInk } from '../../features/scene/sceneShell.css';
@ -20,11 +20,16 @@ export const NavCategory = style([
export const NavCategoryHeader = style({
gap: config.space.S100,
selectors: {
// Category labels sit on the scene like the rows do, and need a wash to be read.
':root[data-scene] &': {
backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_LABEL}, transparent)`,
borderRadius: config.radii.R400,
'@media': {
// Category labels sit on the scene like the rows do, and need a wash to be read (wide screens;
// a phone's list is plain text).
'screen and (min-width: 751px)': {
selectors: {
':root[data-scene] &': {
backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_LABEL}, transparent)`,
borderRadius: config.radii.R400,
},
},
},
},
});
@ -60,6 +65,12 @@ const getVariant = (variant: ContainerColor): ComplexStyleRule => ({
},
});
// The room list is plain text on a phone: no pill, slab or border behind any row, and hover,
// selected and pressed show in the label (NavLabel), never in a box. Wider screens keep the rows
// and the wash.
const WIDE = 'screen and (min-width: 751px)';
const PHONE = 'screen and (max-width: 750px)';
const NavItemBase = style({
width: '100%',
display: 'flex',
@ -70,35 +81,45 @@ const NavItemBase = style({
outline: 'none',
minHeight: toRem(36),
selectors: {
'&:hover, &:focus-visible': {
backgroundColor: ContainerHover,
'@media': {
[PHONE]: {
backgroundColor: 'transparent',
borderRadius: 0,
},
'&[data-hover=true]': {
backgroundColor: ContainerHover,
},
// While the background scene runs the shell is clear (sceneShell.css.ts); a row is a faint
// wash of the theme's own surface: the terrain stays visible, the text stays readable. Hover,
// the selected row and a press each strengthen it a step.
':root[data-scene] &': {
backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_ROW}, transparent)`,
},
':root[data-scene] &:hover, :root[data-scene] &:focus-visible, :root[data-scene] &[data-hover=true]':
{
backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_HOVER}, transparent)`,
[WIDE]: {
selectors: {
'&:hover, &:focus-visible': {
backgroundColor: ContainerHover,
},
'&[data-hover=true]': {
backgroundColor: ContainerHover,
},
// While the background scene runs the shell is clear (sceneShell.css.ts); a row is a faint
// wash of the theme's own surface: the terrain stays visible, the text stays readable.
// Hover, the selected row and a press each strengthen it a step.
':root[data-scene] &': {
backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_ROW}, transparent)`,
},
':root[data-scene] &:hover, :root[data-scene] &:focus-visible, :root[data-scene] &[data-hover=true]':
{
backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_HOVER}, transparent)`,
},
':root[data-scene] &[aria-selected=true]': {
backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_SELECTED}, transparent)`,
},
[`:root[data-scene] &:has(.${NavLink}:active)`]: {
backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_PRESSED}, transparent)`,
},
[`&:has(.${NavLink}:active)`]: {
backgroundColor: ContainerActive,
},
'&[aria-selected=true]': {
backgroundColor: ContainerActive,
},
},
':root[data-scene] &[aria-selected=true]': {
backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_SELECTED}, transparent)`,
},
[`:root[data-scene] &:has(.${NavLink}:active)`]: {
backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_PRESSED}, transparent)`,
},
[`&:has(.${NavLink}:active)`]: {
backgroundColor: ContainerActive,
},
'&[aria-selected=true]': {
backgroundColor: ContainerActive,
},
},
selectors: {
[`&:has(.${NavLink}:focus-visible)`]: {
outline: `${config.borderWidth.B600} solid ${ContainerLine}`,
outlineOffset: `calc(-1 * ${config.borderWidth.B600})`,
@ -158,3 +179,76 @@ export const NavItemContent = style({
export const NavItemOptions = style({
paddingRight: config.space.S200,
});
// A room's name on a phone wears the section headings' type (small caps-height, uppercase, 600,
// at heading strength) and states itself in the text: hover and focus underline it, the selected
// row is bolder and underlined, a press dims it, an unread room is at full strength. Over the scene
// a halo of the theme's own surface keeps it readable without a box.
export const NavLabel = style({});
const label = `.${NavLabel}.${NavLabel}`;
const phoneLabel = (selector: string, rule: StyleRule) =>
globalStyle(selector, { '@media': { [PHONE]: rule } });
phoneLabel(label, {
fontSize: toRem(12),
lineHeight: toRem(16),
fontWeight: config.fontWeight.W600,
textTransform: 'uppercase',
opacity: 0.75,
textDecorationThickness: '1px',
textUnderlineOffset: toRem(4),
});
phoneLabel(
[
`.${NavItemBase}:hover ${label}`,
`.${NavItemBase}:focus-visible ${label}`,
`.${NavItemBase}[data-hover=true] ${label}`,
`.${NavItemBase}[data-highlight=true] ${label}`,
].join(', '),
{ opacity: 1 }
);
phoneLabel(`.${NavItemBase}:hover ${label}, .${NavItemBase}:focus-visible ${label}`, {
textDecorationLine: 'underline',
});
phoneLabel(`.${NavItemBase}[aria-selected=true] ${label}`, {
opacity: 1,
fontWeight: config.fontWeight.W700,
textDecorationLine: 'underline',
textDecorationThickness: '2px',
});
phoneLabel(`.${NavItemBase}:has(.${NavLink}:active) ${label}`, {
opacity: 0.6,
textDecorationLine: 'underline',
});
phoneLabel(`:root[data-scene] ${label}`, {
textShadow: `0 0 3px ${surfaceInk}, 0 0 8px ${surfaceInk}`,
});
// Icons, dots and the Join action share the label's halo over the scene.
globalStyle(`:root[data-scene] ${NavItemBase} svg, :root[data-scene] ${NavItemBase} button`, {
'@media': {
[PHONE]: {
filter: `drop-shadow(0 0 3px ${surfaceInk})`,
},
},
});
// The Join action on a phone is a plain underlined word, not a pill.
export const NavAction = style({
'@media': {
[PHONE]: {
selectors: {
'&&': {
backgroundColor: 'transparent',
boxShadow: 'none',
border: 'none',
borderRadius: 0,
minWidth: 0,
textDecoration: 'underline',
textUnderlineOffset: toRem(4),
flexShrink: 0,
},
},
},
},
});

View file

@ -10,6 +10,7 @@ import {
NavItem,
NavItemContent,
} from '../../components/nav';
import { NavAction, NavLabel } from '../../components/nav/styles.css';
import { UseStateProvider } from '../../components/UseStateProvider';
import { JoinAddressPrompt } from '../../components/join-address-prompt';
import { RoomNavCategoryButton, RoomNavItem } from '../room-nav';
@ -80,7 +81,7 @@ function JoinRow({ room }: { room: EstateRoom }) {
<Avatar size="200" radii="400">
<Icon src={Icons.Hash} size="100" style={{ opacity: config.opacity.P300 }} />
</Avatar>
<Text as="span" size="Inherit" truncate>
<Text className={NavLabel} as="span" size="Inherit" truncate>
{room.name}
</Text>
</Box>
@ -95,6 +96,7 @@ function JoinRow({ room }: { room: EstateRoom }) {
</Text>
)}
<Chip
className={NavAction}
variant={room.state === 'invited' ? 'Primary' : 'Secondary'}
fill="Soft"
radii="Pill"
@ -281,6 +283,8 @@ export function EstateNavigator() {
const scrollRef = useRef<HTMLDivElement>(null);
const mx = useMatrixClient();
const title = (rootId && mx.getRoom(rootId)?.name) || 'GameCult';
// A phone's list is the estate and nothing else: no Create Room, no Join with Address.
const mobile = useScreenSizeContext() === ScreenSize.Mobile;
return (
<PageNav>
@ -302,7 +306,7 @@ export function EstateNavigator() {
</PageNavHeader>
<PageNavContent scrollRef={scrollRef}>
<Box direction="Column" gap="300" className={css.Targets}>
<EntryPoints />
{!mobile && <EntryPoints />}
{rootId && <EstateTiers rootId={rootId} />}
{!rootId && error && (
<EstateError message="Could not find the GameCult space. Check your connection and reload." />

View file

@ -21,6 +21,7 @@ import { useFocusWithin, useHover } from 'react-aria';
import FocusTrap from 'focus-trap-react';
import { useAtom, useAtomValue } from 'jotai';
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
import { NavLabel } from '../../components/nav/styles.css';
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl, getStateEvent } from '../../utils/room';
@ -358,7 +359,13 @@ export function RoomNavItem({
)}
</Avatar>
<Box as="span" grow="Yes">
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
<Text
className={NavLabel}
priority={unread ? '500' : '300'}
as="span"
size="Inherit"
truncate
>
{roomName}
</Text>
</Box>

View file

@ -1,8 +1,26 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
import { surfaceInk } from '../scene/sceneShell.css';
export const CategoryButton = style({
flexGrow: 1,
'@media': {
// A section heading on a phone is plain text: no pill behind it.
'screen and (max-width: 750px)': {
selectors: {
'&&': {
backgroundColor: 'transparent',
boxShadow: 'none',
border: 'none',
borderRadius: 0,
},
':root[data-scene] &&': {
textShadow: `0 0 3px ${surfaceInk}, 0 0 8px ${surfaceInk}`,
filter: `drop-shadow(0 0 3px ${surfaceInk})`,
},
},
},
},
});
export const CategoryButtonIcon = style({
opacity: config.opacity.P400,