Compare commits

...
6 Commits
Author SHA1 Message Date
jaredandClaude Opus 4.8 291e14ab48 docs(todo): mobile r2 — embed-card stacking + secondary touch sweep done
CI / Trigger Desktop Build (push) Successful in 10s
CI / Build & Quality Checks (push) Successful in 10m42s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 01:57:29 -04:00
jaredandClaude Opus 4.8 72e7447d28 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>
2026-07-19 01:56:55 -04:00
jaredandClaude Opus 4.8 37d647d931 docs(todo): mobile follow-ups — P1 touch targets + P2 reduced-motion done
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 00:30:31 -04:00
jaredandClaude Opus 4.8 c3e1fbfff5 fix(a11y): honor prefers-reduced-motion for avatar decorations (P2)
Avatar decorations are animated APNGs and were the only motion feature not
gated on prefers-reduced-motion (chat backgrounds / seasonal overlays all
suppress motion under it). Since there's no static-frame asset to freeze to,
render just the avatar (no decoration overlay) when the user prefers reduced
motion — the only motion-respecting option. Users without the preference are
unaffected; live OS-toggle is reactive via useReducedMotion. Also relieves the
mobile perf drain of dozens of live APNGs in scrolling lists.

Reviewed: correct a11y behavior, hooks-safe, no layout dependency on the overlay.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 00:00:07 -04:00
jaredandClaude Opus 4.8 8a1168bc5f 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 <noreply@anthropic.com>
2026-07-18 23:57:55 -04:00
jaredandClaude Opus 4.8 1a3b1310b4 docs(todo): record mobile-audit code pass + deferred items
Mark the code-level mobile responsive audit done (M1-M6 + N1-N2 shipped) and
list what remains: runtime device QA, the Element Call fork in-call mobile UI,
M2/iOS touch discoverability, the sub-44px sweep, avatar-decoration reduced-
motion, and the Twitch/Twitter/TikTok card restructuring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 23:40:39 -04:00
12 changed files with 111 additions and 7 deletions
+18 -2
View File
@@ -204,9 +204,25 @@ Shipped in the EC fork (DeepFilterNet3 default-capable / DTLN / RNNoise / Speex;
Phase 1 shipped: `io.lotus.set_deafen` (LiveKit-source deafen/screenshare-audio-mute) replaces the brittle `<audio>.muted` iframe hack; cinny sends it join-gated alongside the transitional DOM fallback. **Phase 2 (blocked on user npm publish):** publish fork `0.20.1-lotus.2` → bump cinny pin `lotus.1``lotus.2` → delete the `CallControl.ts` `.muted` fallback + the EC1EC6 fixes ship. **Deferred pieces (P6-2b):** the `useCallSpeakers` DOM-scrape is a dormant fallback behind `io.lotus.call_state`; `.click()`-by-`data-testid` UI toggles are low-value fork surface. Divergence to confirm: deafen doesn't silence soundboard/`Unknown`-source audio (setVolume type limit).
### [ ] Mobile audit
### [~] Mobile audit — code-level pass DONE (device QA + deferred items open)
Comprehensive audit of all LOTUS_FEATURES.md features for mobile PWA usability + responsiveness. Method: 44px touch targets, no horizontal overflow, full-screen modals/drawers on mobile, composer not obscured by keyboard.
Comprehensive **code-level** responsive audit of the LOTUS_FEATURES surface (12 survey agents — 6 area slices + 6 deep per-feature dives — each finding verified, fixed in reviewed batches, then a 5-agent all-files regression+efficacy gate; gate-green tsc/eslint/857 tests/build). Shipped `lotus` commits `d6159997` `836e4a66` `4c298a36` `09415f95` `36fdbdd3` `154e35ef` `09f37f89` (M1M6 + N1N2): message-table/composer/call-bar/url-preview/explore overflow fixes; full-screen media/file/avatar viewers + touch-pan for zoomed images; full-screen scrollable member profile (+close btn); native SettingsSelect + tile-body volume sliders + measured GifPicker; full-screen Report/"Seen by" dialogs + full-width toasts + popover clamps; **image/video aspect-ratio (no crop/letterbox on phones)**; 44px room-row + space-rail touch targets. The app was found **structurally sound** on mobile (thread panel, dialogs, drawers, settings shells, ACL/widgets/search/QR/auth all already responsive).
Intentional desktop deltas (disclosed, non-regressive): volume sliders below labels; Report dialog 380→480px & "Seen by" modals 460→360px (sibling-modal normalization); translate select → folds SettingsSelect.
**NOT done — needs a real device / product decisions (open):**
- [ ] **Runtime mobile QA** — none of the above is validated on an actual phone (static analysis only). Needs device/devtools walk-through per LOTUS_TESTING §E.
- [ ] **Element Call fork in-call mobile UI** — the EC iframe (video grid, EC control bar, spotlight) is `LotusGuild/element-call` at `/root/code/element-call` (we own it); the cinny-side audit couldn't reach it. The actual on-phone call experience is unaudited.
- [ ] **M2 — touch discoverability** — message quick-reactions/actions are hover-gated; long-press is the fallback but is **unreliable on iOS Safari** (deep audit). A visible touch affordance is needed but the naive fix hides unread badges / clutters messages (member-profile-style redesign).
- [~] **Sub-44px touch-target sweep** — primary controls DONE via a shared `MobileTouchTarget` `@media` class (`P1`, `8a1168bc`): in-call bar ×7, call-status bar ×4, thread "N replies" chip, knock Approve/Deny, ACL remove. Secondary batch DONE (`r2`, `72e7447d`): image-viewer close/zoom±/zoom%/download, embed-player Close/Collapse/Fullscreen/View-post, read-receipt "seen by" pill. **Deferred (rationale, not built):** PiP fullscreen/resize handles — enlarging four 24px corners to 44px would swallow a ~160px mobile PiP and block "Return to call" (needs a design rethink, not a blunt bump); presence dot is a non-interactive status indicator (no target needed).
- [x] **Avatar-decoration `prefers-reduced-motion`** — DONE (`P2`, `c3e1fbff`): renders just the avatar (no animated APNG overlay) under the preference; no static-frame asset to freeze to.
- [x] **Twitch/Twitter/TikTok preview cards** — DONE (`r2`, `72e7447d`). These fragment cards render header/thumbnail beside content as direct children of the `UrlPreview` flex row; added `StackOnMobile` (mobile-only `@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 unchanged (verified by 2 review agents). Pre-existing desktop quirk (header bar beside content on Twitter/TikTok at desktop width) left as-is — the fuller fix is wrapping each card body in a column `Box`; out of scope for a mobile pass.
- [ ] **M2 — message action/quick-reaction touch discoverability** — hover-gated + iOS-long-press-unreliable; a visible touch affordance collides with unread-badge placement / per-message clutter → needs a design decision + device look.
### [ ] Inline media embeds — remaining providers (LOW PRIORITY)
The inline embed system (`videoEmbed.ts`) covers 16 providers; three more were **deliberately deferred** (verified against 2026 docs by review agents):
### [ ] Inline media embeds — remaining providers (LOW PRIORITY)
@@ -1,5 +1,6 @@
import React from 'react';
import { useAvatarDecoration } from '../../hooks/useAvatarDecoration';
import { useReducedMotion } from '../../hooks/useReducedMotion';
import { decorationUrl } from '../../features/lotus/avatarDecorations';
const DEFAULT_INSET = 8;
@@ -16,8 +17,14 @@ export function AvatarDecoration({
inset = DEFAULT_INSET,
}: AvatarDecorationProps) {
const slug = useAvatarDecoration(userId);
const reducedMotion = useReducedMotion();
if (!slug) {
// Decorations are animated APNGs with no static asset to freeze to, so honor
// prefers-reduced-motion by not rendering the animation at all (consistent
// with the rest of the theming stack — chat backgrounds / seasonal overlays —
// which all suppress motion under this preference; also avoids dozens of live
// APNGs animating in scrolling mobile lists).
if (!slug || reducedMotion) {
return <>{children}</>;
}
@@ -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/
@@ -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}
+8
View File
@@ -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}
@@ -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 }}
+3
View File
@@ -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)}
>
<Text size="B300">Approve</Text>
@@ -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)}
>
<Text size="B300">Deny</Text>
@@ -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) {
<Chip
variant="SurfaceVariant"
radii="300"
className={MobileTouchTarget}
before={<Icon size="50" src={Icons.Thread} />}
after={
unread > 0 ? <Badge variant="Success" fill="Solid" radii="Pill" size="200" /> : undefined
+18
View File
@@ -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 ~2440px, 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),
},
},
});