From 6768237de4edbebc0d81e2c95759e718e8cafe9c Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 15:08:46 +0000 Subject: [PATCH] Room list on a phone: plain text rows in the heading type; no Create Room or Join with Address Rows, section headings and the Join action lose their pill, slab and border below 750px. Room names take the section headings' type (12px, uppercase, 600, heading strength); hover and focus underline them, the selected row is bolder and underlined, a press dims, an unread room is at full strength. Over the scene a halo of the theme's surface keeps text, icons and dots legible. The phone list no longer shows the Create Room and Join with Address entries (routes and the prompt stay for wider screens). The portrait overhang band is now the header's min-height, so the 58px row margin no longer stacks on the header. Co-Authored-By: Claude Sonnet 5.5 --- .../components/message/layout/layout.css.ts | 17 +- src/app/components/nav/styles.css.ts | 158 ++++++++++++++---- src/app/features/estate/EstateNavigator.tsx | 8 +- src/app/features/room-nav/RoomNavItem.tsx | 9 +- src/app/features/room-nav/styles.css.ts | 18 ++ 5 files changed, 174 insertions(+), 36 deletions(-) diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 815c579f..a724241c 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -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', }); diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index b123f9bc..feee3276 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -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, + }, + }, + }, + }, +}); diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 29451c92..1a9ca8ee 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -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 }) { - + {room.name} @@ -95,6 +96,7 @@ function JoinRow({ room }: { room: EstateRoom }) { )} (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 ( @@ -302,7 +306,7 @@ export function EstateNavigator() { - + {!mobile && } {rootId && } {!rootId && error && ( diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 08341113..ff23930e 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -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({ )} - + {roomName} diff --git a/src/app/features/room-nav/styles.css.ts b/src/app/features/room-nav/styles.css.ts index b5701a6f..b9655923 100644 --- a/src/app/features/room-nav/styles.css.ts +++ b/src/app/features/room-nav/styles.css.ts @@ -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, -- 2.47.3