From 8a1168bc5f4684768e1f4c68d8d343d00d7cc6f4 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 18 Jul 2026 23:57:55 -0400 Subject: [PATCH] fix(mobile): 44px touch targets for primary call/thread/mod controls (P1) Deep-audit follow-up. New shared MobileTouchTarget class (@media <=750px -> minWidth/minHeight 44px) applied via className to the primary interactive controls folds renders below 44px: - in-call control bar (7 buttons) + persistent call-status bar (4 buttons) - thread "N replies" chip - knock Approve/Deny buttons - server-ACL entry remove button folds size variants set only padding (no width/height/min-*), so the class raises the hit-area floor to 44px with the icon/label staying centered at its normal size; desktop is untouched (@media-gated). Verified by two review passes (no distortion, no layout overflow, counts exact). Co-Authored-By: Claude Opus 4.8 --- src/app/features/call-status/CallControl.tsx | 5 +++++ src/app/features/call/Controls.tsx | 8 ++++++++ .../features/room-settings/RoomServerACL.tsx | 2 ++ src/app/features/room/MembersDrawer.tsx | 3 +++ src/app/features/room/thread/ThreadSummary.tsx | 2 ++ src/app/styles/mobile.css.ts | 18 ++++++++++++++++++ 6 files changed, 38 insertions(+) create mode 100644 src/app/styles/mobile.css.ts diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx index fa5c60823..1f3e929ed 100644 --- a/src/app/features/call-status/CallControl.tsx +++ b/src/app/features/call-status/CallControl.tsx @@ -5,6 +5,7 @@ import { StatusDivider } from './components'; import { CallEmbed, useCallControlState } from '../../plugins/call'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { callEmbedAtom } from '../../state/callEmbed'; +import { MobileTouchTarget } from '../../styles/mobile.css'; type MicrophoneButtonProps = { enabled: boolean; @@ -31,6 +32,7 @@ function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps fill="Soft" radii="300" size="300" + className={MobileTouchTarget} onClick={toggleMic} outlined disabled={disabled || loading} @@ -66,6 +68,7 @@ function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) { fill="Soft" radii="300" size="300" + className={MobileTouchTarget} onClick={() => onToggle()} aria-label={enabled ? 'Deafen' : 'Undeafen'} aria-pressed={enabled} @@ -108,6 +111,7 @@ function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) { fill="Soft" radii="300" size="300" + className={MobileTouchTarget} onClick={toggleVideo} aria-label={enabled ? 'Stop Video' : 'Start Video'} aria-pressed={enabled} @@ -147,6 +151,7 @@ function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonPro fill="Soft" radii="300" size="300" + className={MobileTouchTarget} onClick={onToggle} aria-label={enabled ? 'Stop Screenshare' : 'Start Screenshare'} aria-pressed={enabled} diff --git a/src/app/features/call/Controls.tsx b/src/app/features/call/Controls.tsx index 3bf8ac7c2..f5f3e81d5 100644 --- a/src/app/features/call/Controls.tsx +++ b/src/app/features/call/Controls.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Icon, IconButton, Icons, Line, Text, Tooltip, TooltipProvider } from 'folds'; import { useAtom } from 'jotai'; import * as css from './styles.css'; +import { MobileTouchTarget } from '../../styles/mobile.css'; import { callChatAtom } from '../../state/callEmbed'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; @@ -36,6 +37,7 @@ export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) { fill="Soft" radii="400" size="400" + className={MobileTouchTarget} onClick={toggleMic} aria-label={enabled ? 'Turn Off Microphone' : 'Turn On Microphone'} outlined @@ -70,6 +72,7 @@ export function SoundButton({ enabled, onToggle }: SoundButtonProps) { fill="Soft" radii="400" size="400" + className={MobileTouchTarget} onClick={() => onToggle()} aria-label={enabled ? 'Undeafen' : 'Deafen'} outlined @@ -113,6 +116,7 @@ export function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) { fill="Soft" radii="400" size="400" + className={MobileTouchTarget} onClick={toggleVideo} outlined disabled={disabled || loading} @@ -155,6 +159,7 @@ export function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps) fill="Soft" radii="400" size="400" + className={MobileTouchTarget} onClick={() => onToggle()} aria-label={enabled ? 'Stop Screenshare' : 'Start Screenshare'} outlined @@ -200,6 +205,7 @@ export function FullscreenButton({ isFullscreen, onToggle }: FullscreenButtonPro fill="Soft" radii="400" size="400" + className={MobileTouchTarget} onClick={onToggle} aria-label={isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'} aria-pressed={isFullscreen} @@ -234,6 +240,7 @@ export function ScreenshareAudioButton({ muted, onToggle }: ScreenshareAudioButt fill="Soft" radii="400" size="400" + className={MobileTouchTarget} onClick={onToggle} aria-label={muted ? 'Unmute Screenshare Audio' : 'Mute Screenshare Audio'} aria-pressed={muted} @@ -266,6 +273,7 @@ export function ChatButton() { fill="Soft" radii="400" size="400" + className={MobileTouchTarget} onClick={() => setChat(!chat)} aria-label={chat ? 'Close Chat' : 'Open Chat'} aria-pressed={chat} diff --git a/src/app/features/room-settings/RoomServerACL.tsx b/src/app/features/room-settings/RoomServerACL.tsx index 98531dd69..aea24e43d 100644 --- a/src/app/features/room-settings/RoomServerACL.tsx +++ b/src/app/features/room-settings/RoomServerACL.tsx @@ -33,6 +33,7 @@ import { SequenceCard } from '../../components/sequence-card'; import { SequenceCardStyle } from '../common-settings/styles.css'; import { stopPropagation } from '../../utils/keyboard'; import { isValidServerPattern, matchesAnyGlob } from '../../utils/serverAcl'; +import { MobileTouchTarget } from '../../styles/mobile.css'; import { useModalStyle } from '../../hooks/useModalStyle'; // ── Types ───────────────────────────────────────────────────────────────────── @@ -148,6 +149,7 @@ function ServerList({ label, entries, canEdit, onAdd, onRemove }: ServerListProp size="300" variant="Background" radii="300" + className={MobileTouchTarget} aria-label={`Remove ${entry}`} onClick={() => onRemove(i)} style={{ flexShrink: 0 }} diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx index 69e6beb33..54fb4ff1b 100644 --- a/src/app/features/room/MembersDrawer.tsx +++ b/src/app/features/room/MembersDrawer.tsx @@ -30,6 +30,7 @@ import { import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk'; import { useVirtualizer } from '@tanstack/react-virtual'; import classNames from 'classnames'; +import { MobileTouchTarget } from '../../styles/mobile.css'; import { Membership } from '../../../types/matrix/room'; import * as css from './MembersDrawer.css'; @@ -460,6 +461,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) { variant="Success" radii="300" fill="Soft" + className={MobileTouchTarget} onClick={() => mx.invite(room.roomId, knockMember.userId)} > Approve @@ -469,6 +471,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) { variant="Critical" radii="300" fill="Soft" + className={MobileTouchTarget} onClick={() => mx.kick(room.roomId, knockMember.userId)} > Deny diff --git a/src/app/features/room/thread/ThreadSummary.tsx b/src/app/features/room/thread/ThreadSummary.tsx index 10bce29e6..875ae5d49 100644 --- a/src/app/features/room/thread/ThreadSummary.tsx +++ b/src/app/features/room/thread/ThreadSummary.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Badge, Box, Chip, Icon, Icons, Text, config } from 'folds'; import { MatrixEvent, Room } from 'matrix-js-sdk'; +import { MobileTouchTarget } from '../../../styles/mobile.css'; import { useThreadSummary } from '../../../hooks/useThreadSummary'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; @@ -31,6 +32,7 @@ export function ThreadSummary({ rootEvent, room, onOpen }: ThreadSummaryProps) { } after={ unread > 0 ? : undefined diff --git a/src/app/styles/mobile.css.ts b/src/app/styles/mobile.css.ts new file mode 100644 index 000000000..e08d9fc65 --- /dev/null +++ b/src/app/styles/mobile.css.ts @@ -0,0 +1,18 @@ +import { style } from '@vanilla-extract/css'; +import { toRem } from 'folds'; + +/** + * A 44px minimum touch target on phones for otherwise-small interactive controls + * (folds `IconButton`/`Chip` at `size="300"`/`"400"` render ~24–40px, below the + * 44px guideline). Apply via `className`; the icon/label stays its normal visual + * size — only the hit area grows — and desktop is unchanged (the rule is gated to + * `@media (max-width: 750px)`, matching MOBILE_BREAKPOINT). + */ +export const MobileTouchTarget = style({ + '@media': { + '(max-width: 750px)': { + minWidth: toRem(44), + minHeight: toRem(44), + }, + }, +});