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,