fix(mobile): stack embed cards + secondary 44px touch targets (r2)

Mobile follow-ups round 2 (survey findings deferred from the mobile audit),
reviewed by 2 agents on the staged diff (both SHIP).

- URL-preview cards: the Twitch / Twitter / TikTok-fallback cards render
  their thumbnail/header BESIDE the content as direct children of the
  UrlPreview flex row, which squeezes both on a phone. Add `StackOnMobile`
  (@media max-width:750px -> flex-direction:column) scoped to those variants
  via cardClass. folds Box has no default `direction`, so the override wins
  uncontested; desktop (>750px) is unchanged. No-op for the single-column
  embed cards (MediaEmbedCard/TikTokEmbedCard).
- 44px touch targets (MobileTouchTarget, @media max-width:750px) on the
  otherwise ~28px controls: embed-player Close/Collapse/Fullscreen/View-post
  buttons; image-viewer close/zoom/download; the read-receipt "seen by" pill.

Deferred (rationale, not built): PiP resize handles + fullscreen button —
enlarging four 24px corners to 44px would swallow a ~160px mobile PiP and
block "Return to call"; presence dot is a non-interactive status indicator.

Gates: tsc 0, eslint 0, prettier clean, 856/856 tests, build ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 01:56:55 -04:00
co-authored by Claude Opus 4.8
parent 37d647d931
commit 72e7447d28
4 changed files with 47 additions and 4 deletions
@@ -7,6 +7,7 @@ import * as css from './ImageViewer.css';
import { useZoom } from '../../hooks/useZoom';
import { usePan } from '../../hooks/usePan';
import { downloadMedia } from '../../utils/matrix';
import { MobileTouchTarget } from '../../styles/mobile.css';
export type ImageViewerProps = {
alt: string;
@@ -35,7 +36,13 @@ export const ImageViewer = as<'div', ImageViewerProps>(
>
<Header className={css.ImageViewerHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200">
<IconButton size="300" radii="300" onClick={requestClose} aria-label="Close">
<IconButton
size="300"
radii="300"
className={MobileTouchTarget}
onClick={requestClose}
aria-label="Close"
>
<Icon size="50" src={Icons.ArrowLeft} />
</IconButton>
<Text size="T300" truncate>
@@ -48,12 +55,18 @@ export const ImageViewer = as<'div', ImageViewerProps>(
outlined={zoom < 1}
size="300"
radii="Pill"
className={MobileTouchTarget}
onClick={zoomOut}
aria-label="Zoom Out"
>
<Icon size="50" src={Icons.Minus} />
</IconButton>
<Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
<Chip
variant="SurfaceVariant"
radii="Pill"
className={MobileTouchTarget}
onClick={() => setZoom(zoom === 1 ? 2 : 1)}
>
<Text size="B300">{Math.round(zoom * 100)}%</Text>
</Chip>
<IconButton
@@ -61,6 +74,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
outlined={zoom > 1}
size="300"
radii="Pill"
className={MobileTouchTarget}
onClick={zoomIn}
aria-label="Zoom In"
>
@@ -70,6 +84,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
variant="Primary"
onClick={handleDownload}
radii="300"
className={MobileTouchTarget}
before={<Icon size="50" src={Icons.Download} />}
>
<Text size="B300">{t('Organisms.ImageViewer.download')}</Text>
@@ -22,6 +22,7 @@ import { stopPropagation } from '../../utils/keyboard';
import { useModalStyle } from '../../hooks/useModalStyle';
import { useMemberAvatar } from '../../hooks/useMemberAvatar';
import { useRoomMembersChange } from '../../hooks/useRoomMemberChange';
import { MobileTouchTarget } from '../../styles/mobile.css';
import * as css from './ReadReceiptAvatars.css';
const MAX_DISPLAY = 5;
@@ -92,7 +93,7 @@ export function ReadReceiptAvatars({
onClick={() => setOpen(true)}
title={tooltipNames}
aria-label={tooltipNames}
className={css.ReceiptTrigger}
className={`${css.ReceiptTrigger} ${MobileTouchTarget}`}
>
{/* Pill wrapper ensures visibility on any wallpaper/background */}
<span
@@ -23,6 +23,19 @@ export const UrlPreviewWide = style({
width: 'min(38rem, 94vw)',
});
// The Twitch/Twitter/TikTok-fallback cards lay their header/thumbnail out BESIDE
// the content as direct children of the UrlPreview flex row; on a phone that
// squeezes both. Stack them vertically on narrow viewports only. `UrlPreview`'s
// Box has no explicit direction (browser default row), so this override wins
// with nothing to compete against, and desktop (>750px) is unchanged.
export const StackOnMobile = style({
'@media': {
'(max-width: 750px)': {
flexDirection: 'column',
},
},
});
export const UrlPreviewImg = style([
DefaultReset,
{
@@ -15,6 +15,7 @@ import {
config,
} from 'folds';
import { ImageOverlay } from '../ImageOverlay';
import { MobileTouchTarget } from '../../styles/mobile.css';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { UrlPreview, UrlPreviewContent, UrlPreviewDescription, UrlPreviewImg } from './UrlPreview';
@@ -726,6 +727,7 @@ function TwitterCard({
size="300"
radii="300"
variant="SurfaceVariant"
className={MobileTouchTarget}
onClick={() => setExpanded(false)}
aria-label="Collapse post"
>
@@ -780,6 +782,7 @@ function TwitterCard({
<Chip
variant="Secondary"
radii="Pill"
className={MobileTouchTarget}
onClick={() => setExpanded(true)}
before={<Icon size="50" src={Icons.Play} />}
>
@@ -1249,6 +1252,7 @@ function MediaEmbedCard({
<Chip
variant="Secondary"
radii="Pill"
className={MobileTouchTarget}
onClick={enterFullscreen}
aria-label="Fullscreen"
>
@@ -1259,6 +1263,7 @@ function MediaEmbedCard({
size="300"
radii="300"
variant="SurfaceVariant"
className={MobileTouchTarget}
onClick={() => setPlaying(false)}
aria-label="Close player"
>
@@ -1407,6 +1412,7 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
<Chip
variant="Secondary"
radii="Pill"
className={MobileTouchTarget}
onClick={enterFullscreen}
aria-label="Fullscreen"
>
@@ -1416,6 +1422,7 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
size="300"
radii="300"
variant="SurfaceVariant"
className={MobileTouchTarget}
onClick={() => setPlaying(false)}
aria-label="Close player"
>
@@ -2069,7 +2076,14 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
// player chrome / tweet content isn't cramped or clipped.
const embed = parseMediaEmbed(url, window.location.hostname);
const wide = !!embed || isTwitterTweet(url);
const cardClass = wide ? previewCss.UrlPreviewWide : undefined;
// Twitter/Twitch/TikTok(fallback) cards render header/thumbnail beside content
// in the card flex row; stack them on phones (no-op for the single-column
// embed cards). Desktop keeps the row layout.
const stackOnMobile = isTwitter(url) || isTwitch(url) || isTikTok(url);
const cardClass =
[wide && previewCss.UrlPreviewWide, stackOnMobile && previewCss.StackOnMobile]
.filter(Boolean)
.join(' ') || undefined;
const renderContent = (prev: IPreviewUrlResponse): React.ReactNode => {
// Embeddable media (YouTube/Vimeo/TikTok/Dailymotion/Streamable/Twitch/