Compare commits

...
7 Commits
Author SHA1 Message Date
jaredandClaude Opus 4.8 09f37f890f fix(mobile): image/video aspect-ratio so media doesn't crop/letterbox (N2)
CI / Trigger Desktop Build (push) Successful in 6s
CI / Build & Quality Checks (push) Successful in 10m53s
MImage/MVideo pinned AttachmentBox to a fixed height computed for a 400px-wide
layout. The box width is responsive (maxWidth:100%) but the height was frozen,
so on a phone the box narrows below 400px while keeping desktop height ->
images crop (object-fit:cover) and videos letterbox (object-fit:contain).

Drive the box by `aspect-ratio: w/h` when intrinsic dimensions are known, so the
height tracks the responsive width. On desktop the box stays 400px wide, so the
aspect-ratio yields the identical height (algebraically 400*h/w =
scaleYDimension(w,400,h)) — pixel-identical. Falls back to the fixed height when
dimensions are unknown; the 48px floor and 600px cap are preserved.

Uses the same pattern already shipped in this codebase (TwitchThumbnailWrapper,
GalleryTile). Two review passes, one empirically measuring the rendered image in
Chromium: desktop unchanged, narrow widths keep correct aspect, no collapse.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 23:28:20 -04:00
jaredandClaude Opus 4.8 154e35ef9f fix(mobile): deep-audit structural fixes — dialogs, toasts, call bar (N1)
From the 6-agent deep per-feature audit. Mobile-gated / consistency fixes;
desktop unchanged except two intentional dialog-width normalizations noted below.

- In-call control bar: wrap="Wrap" on the SequenceCard so the compact two-group
  row wraps on the narrowest phones (<=390px) instead of pushing End off-screen
  (M1 fixed the 500-750px band; this covers narrower). Desktop stays one row.
- In-call soundboard popout: clamp maxWidth to the viewport (like M5's screenshare
  popover) so it can't overflow a narrow phone.
- Report-Message dialog + "Seen by" (EventReaders) modals (Message.tsx x2 +
  RoomViewFollowing): add useModalStyle so they go full-screen on mobile like
  their sibling report/receipt modals (they floated as fixed cards before).
- In-app toast container: full-width toasts inset from both edges on mobile
  (ScreenSize.Mobile); a fixed 280-340px card previously overflowed a narrow
  phone. Desktop byte-identical (bottom-right floating card).
- Policy-list tabs + audio-controls rows: wrap="Wrap" (inert on desktop).

Intentional desktop deltas (normalizing to existing sibling modals, verified by
two review passes as consistent, not regressions): Report dialog max-width
380->480px; EventReaders modals 460->360px.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 23:22:59 -04:00
jaredandClaude Opus 4.8 36fdbdd399 fix(mobile): 44px touch targets for room rows + space rail (M6)
Mobile-audit batch 6. Bump the primary always-visible tap targets to a 44px
touch area on phones, via mobile-gated CSS only (desktop/tablet >750px keep the
denser sizing).

- NavItemBase (room/nav list row): minHeight 36 -> 44 at <=750px.
- SidebarItem (space-rail button): minWidth/minHeight -> 44 at <=750px (was 42).

The room lists are virtualized with ref=virtualizer.measureElement on every row,
so rows are measured to their actual height — the taller mobile rows can't
overlap/clip. Verified desktop-unchanged and virtualizer-safe by two review
passes. (A blanket app-wide size=300 button sweep was intentionally NOT done:
most such buttons are hover-gated and never appear on mobile; the primary
tap targets above are the high-value fixes.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 22:41:36 -04:00
jaredandClaude Opus 4.8 09415f95c0 fix(mobile): native settings controls + soundboard/gif/call polish (M5)
Mobile-audit batch 5. Desktop provably unchanged (two review passes).

- Translate-language control: raw <select> (crowded narrow tiles + broke under
  non-default themes) -> the folds-native SettingsSelect used by every other
  dropdown in the settings screen (native-cinny; keeps aria-label).
- Ringtone/Soundboard volume sliders: moved from the fixed-width tile `after`
  slot (which squeezed the title on phones) to a full-width slider in the tile
  body, matching the night-light slider pattern.
- Screenshare-confirm popover: clamp maxWidth to the viewport so it can't run
  past the screen edges on a phone (inert on desktop).
- In-call soundboard editor rows wrap on a narrow popout instead of crushing the
  clip-name field.
- GifPicker: feed the giphy Grid the measured container width (useElementSize
  Observer) instead of a fixed 296px, so it doesn't overflow a <312px phone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 22:38:01 -04:00
jaredandClaude Opus 4.8 4c298a36b4 fix(mobile): full-screen member profile + permissions row wrap (M4)
Mobile-audit batch 4. Desktop paths unchanged (verified by two review passes).

- UserRoomProfileRenderer: the member/room profile was always an anchored,
  fixed-width (340px), non-scrolling PopOut, so on a phone the moderation
  actions / device list / notes fell off the bottom, unreachable. On
  ScreenSize.Mobile it now renders a full-screen, internally-scrollable Modal
  with an explicit Close button (the full-screen sheet covers the backdrop and
  the profile has no self-close, so a tap-to-dismiss / X is required); desktop
  keeps the exact same anchored PopOut. Uses the provider-free useScreenSize().
- PowersEditor: the Color/Name/Power row wraps on narrow widths (wrap="Wrap")
  instead of squishing the name field; inert at desktop widths.

The mobile close button addresses a dismissal-trap both reviewers flagged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 22:30:50 -04:00
jaredandClaude Opus 4.8 836e4a6679 fix(mobile): full-screen media viewers + touch-pan for zoomed images (M3)
Mobile-audit batch 3. All changes mobile-gated (@media <=750px) so desktop is
unchanged.

- ModalWide: fill the phone screen (100vw/100vh, no radius) at <=750px instead
  of floating as an 85vw card. This also full-screens the file/PDF viewer and
  the avatar-crop editor on mobile (they share ModalWide) — intended.
- UserHero avatar viewer: new mobile-only ModalMobileFull class (no desktop
  effect) so it goes edge-to-edge on phones like the timeline lightbox.
- usePan: add touch support (single-finger drag, cleaned up on
  touchend/cancel/unmount) alongside the unchanged mouse path, so a zoomed image
  can be panned on a phone. Wired into ImageViewer and the MediaGallery lightbox.

Two review passes: mouse path byte-for-byte unchanged; desktop provably
unaffected; touch is gated to zoom!=1 so a non-zoomed image never hijacks
swipe/scroll.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 22:24:26 -04:00
jaredandClaude Opus 4.8 d615999737 fix(mobile): overflow breaks — tables, composer, call bar, previews, cards (M1)
Mobile-audit batch 1. All fixes reuse cinny's own responsive primitives and are
mobile-gated so desktop is unchanged.

- Message tables: wrap <table> in an overflow-x container so a wide table scrolls
  instead of overflowing the message column / page body.
- Composer toolbar: let the before|editable|after row and the toolbar wrap on
  phones (@media <=750px) instead of squeezing the editable to zero and pushing
  Send off-screen.
- In-call control bar: collapse to the compact/stacked layout on a mobile
  viewport (ScreenSize.Mobile) too, not just when the bar's own container is
  <500px — fixes the 500-750px band where the control row overflowed.
- URL-preview card: base width toRem(400) -> min(25rem, 92vw) so a single card
  fits a narrow phone (still exactly 400px on desktop).
- Explore card grid: drop to one column at <=750px (was a fixed 3-col grid).

Two review passes: desktop behavior provably unchanged (all gated by @media /
ScreenSize.Mobile; the table wrapper only contains previously-overflowing tables).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 22:14:51 -04:00
25 changed files with 322 additions and 101 deletions
+14 -3
View File
@@ -1,9 +1,10 @@
import React, { useCallback } from 'react'; import React, { useCallback, useRef, useState } from 'react';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import { Grid, SearchBar, SearchContext, SearchContextManager } from '@giphy/react-components'; import { Grid, SearchBar, SearchContext, SearchContextManager } from '@giphy/react-components';
import { IGif } from '@giphy/js-types'; import { IGif } from '@giphy/js-types';
import { Box, color, config } from 'folds'; import { Box, color, config } from 'folds';
import { useElementSizeObserver } from '../hooks/useElementSizeObserver';
import { useSetting } from '../state/hooks/settings'; import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings'; import { settingsAtom } from '../state/settings';
import { addRecentGif, RecentGif, recentGifsAtom } from '../state/recentGifs'; import { addRecentGif, RecentGif, recentGifsAtom } from '../state/recentGifs';
@@ -146,8 +147,18 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
const showRecents = recents.length > 0 && !(term ?? '').trim(); const showRecents = recents.length > 0 && !(term ?? '').trim();
// The container is min(312px, 100vw-16); feed the Grid the live pixel width
// (minus the inner 8px padding on each side) so it doesn't overflow a phone
// narrower than 312px with a fixed 296px grid.
const containerRef = useRef<HTMLDivElement>(null);
const [gridWidth, setGridWidth] = useState(PICKER_WIDTH - 16);
useElementSizeObserver(
useCallback(() => containerRef.current, []),
useCallback((w) => setGridWidth(Math.max(1, Math.floor(w) - 16)), []),
);
return ( return (
<Box direction="Column" style={{ width: PICKER_WIDTH_CSS }}> <Box direction="Column" style={{ width: PICKER_WIDTH_CSS }} ref={containerRef}>
{lotusTerminal && ( {lotusTerminal && (
<div <div
style={{ style={{
@@ -178,7 +189,7 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
<Grid <Grid
key={searchKey} key={searchKey}
fetchGifs={fetchGifs} fetchGifs={fetchGifs}
width={PICKER_WIDTH - 16} width={gridWidth}
columns={2} columns={2}
gutter={4} gutter={4}
onGifClick={handleClick} onGifClick={handleClick}
+73 -16
View File
@@ -1,5 +1,19 @@
import React from 'react'; import React from 'react';
import { Menu, PopOut, toRem } from 'folds'; import {
Box,
Header,
Icon,
IconButton,
Icons,
Menu,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
PopOut,
config,
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { useCloseUserRoomProfile, useUserRoomProfileState } from '../state/hooks/userRoomProfile'; import { useCloseUserRoomProfile, useUserRoomProfileState } from '../state/hooks/userRoomProfile';
import { UserRoomProfile } from './user-profile'; import { UserRoomProfile } from './user-profile';
@@ -8,6 +22,20 @@ import { useAllJoinedRoomsSet, useGetRoom } from '../hooks/useGetRoom';
import { stopPropagation } from '../utils/keyboard'; import { stopPropagation } from '../utils/keyboard';
import { SpaceProvider } from '../hooks/useSpace'; import { SpaceProvider } from '../hooks/useSpace';
import { RoomProvider } from '../hooks/useRoom'; import { RoomProvider } from '../hooks/useRoom';
import { ScreenSize, useScreenSize } from '../hooks/useScreenSize';
// Matches useModalStyle's mobile branch: fill the phone screen with internal
// scroll so tall profiles (moderation actions, device list, notes) are fully
// reachable — the anchored 340px popout below can't scroll and clipped them.
const MOBILE_FULLSCREEN = {
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
borderRadius: 0,
display: 'flex',
flexDirection: 'column',
} as const;
function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState }) { function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState }) {
const { roomId, spaceId, userId, cords, position } = state; const { roomId, spaceId, userId, cords, position } = state;
@@ -15,32 +43,61 @@ function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState })
const getRoom = useGetRoom(allJoinedRooms); const getRoom = useGetRoom(allJoinedRooms);
const room = getRoom(roomId); const room = getRoom(roomId);
const space = spaceId ? getRoom(spaceId) : undefined; const space = spaceId ? getRoom(spaceId) : undefined;
const screenSize = useScreenSize();
const close = useCloseUserRoomProfile(); const close = useCloseUserRoomProfile();
if (!room) return null; if (!room) return null;
const profile = (
<SpaceProvider value={space ?? null}>
<RoomProvider value={room}>
<UserRoomProfile userId={userId} />
</RoomProvider>
</SpaceProvider>
);
const focusTrapOptions = {
initialFocus: false,
onDeactivate: close,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
};
// On phones, render as a full-screen scrollable modal instead of an anchored,
// fixed-width, unscrollable popout.
if (screenSize === ScreenSize.Mobile) {
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap focusTrapOptions={focusTrapOptions}>
<Modal size="500" style={MOBILE_FULLSCREEN}>
{/* Full-screen covers the backdrop (no tap-to-dismiss) and the
profile has no self-close, so provide an explicit close. */}
<Header size="600" style={{ flexShrink: 0, paddingRight: config.space.S200 }}>
<Box grow="Yes" />
<IconButton size="300" radii="300" onClick={close} aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box grow="Yes" style={{ overflow: 'hidden auto' }}>
{profile}
</Box>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
return ( return (
<PopOut <PopOut
anchor={cords} anchor={cords}
position={position ?? 'Top'} position={position ?? 'Top'}
align="Start" align="Start"
content={ content={
<FocusTrap <FocusTrap focusTrapOptions={focusTrapOptions}>
focusTrapOptions={{ <Menu style={{ width: toRem(340) }}>{profile}</Menu>
initialFocus: false,
onDeactivate: close,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ width: toRem(340) }}>
<SpaceProvider value={space ?? null}>
<RoomProvider value={room}>
<UserRoomProfile userId={userId} />
</RoomProvider>
</SpaceProvider>
</Menu>
</FocusTrap> </FocusTrap>
} }
/> />
+14
View File
@@ -16,9 +16,23 @@ export const EditorOptions = style([
DefaultReset, DefaultReset,
{ {
padding: config.space.S200, padding: config.space.S200,
'@media': {
// On phones the toolbar can hold many 44px buttons; let them wrap to a
// second line instead of overflowing horizontally.
'(max-width: 750px)': { flexWrap: 'wrap' },
},
}, },
]); ]);
// The composer's before | editable | after row. On phones, allow the toolbar
// (`after`) to wrap below the input instead of squeezing the editable to zero
// and pushing the Send button off-screen.
export const EditorInputRow = style({
'@media': {
'(max-width: 750px)': { flexWrap: 'wrap' },
},
});
export const EditorTextareaScroll = style({}); export const EditorTextareaScroll = style({});
export const EditorTextarea = style([ export const EditorTextarea = style([
+1 -1
View File
@@ -124,7 +124,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
<div className={css.Editor} ref={ref}> <div className={css.Editor} ref={ref}>
<Slate editor={editor} initialValue={initialValue} onChange={onChange}> <Slate editor={editor} initialValue={initialValue} onChange={onChange}>
{top} {top}
<Box alignItems="Start"> <Box className={css.EditorInputRow} alignItems="Start">
{before && ( {before && (
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No"> <Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
{before} {before}
@@ -19,7 +19,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
const { t } = useTranslation(); const { t } = useTranslation();
const saveFile = useSaveFile(); const saveFile = useSaveFile();
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2); const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const { pan, cursor, onMouseDown } = usePan(zoom !== 1); const { pan, cursor, onMouseDown, onTouchStart } = usePan(zoom !== 1);
const handleDownload = async () => { const handleDownload = async () => {
const fileContent = await downloadMedia(src); const fileContent = await downloadMedia(src);
@@ -100,6 +100,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
src={src} src={src}
alt={alt} alt={alt}
onMouseDown={onMouseDown} onMouseDown={onMouseDown}
onTouchStart={onTouchStart}
/> />
</Box> </Box>
</Box> </Box>
+1 -1
View File
@@ -11,7 +11,7 @@ export const MediaControl = as<'div', MediaControlProps>(
({ before, after, leftControl, rightControl, children, ...props }, ref) => ( ({ before, after, leftControl, rightControl, children, ...props }, ref) => (
<Box grow="Yes" direction="Column" gap="300" {...props} ref={ref}> <Box grow="Yes" direction="Column" gap="300" {...props} ref={ref}>
{before && <Box direction="Column">{before}</Box>} {before && <Box direction="Column">{before}</Box>}
<Box alignItems="Center" gap="200"> <Box alignItems="Center" gap="200" wrap="Wrap">
<Box alignItems="Center" grow="Yes" gap="Inherit"> <Box alignItems="Center" grow="Yes" gap="Inherit">
{leftControl} {leftControl}
</Box> </Box>
+18 -10
View File
@@ -417,6 +417,22 @@ type RenderImageContentProps = {
markedAsSpoiler?: boolean; markedAsSpoiler?: boolean;
spoilerReason?: string; spoilerReason?: string;
}; };
// Media frame sizing. When intrinsic width/height are known, drive the box by
// aspect-ratio so its height tracks the responsive (maxWidth:100%) width — a
// fixed pixel height computed for a 400px-wide layout otherwise crops (images,
// object-fit:cover) or letterboxes (videos, object-fit:contain) on phones where
// the box narrows below 400px. On desktop the box stays 400px wide, so the
// aspect-ratio yields the identical height. Falls back to the fixed height when
// dimensions are unknown.
const attachmentMediaStyle = (
w: number | undefined,
h: number | undefined,
fallbackHeight: number,
): CSSProperties =>
w && h
? { aspectRatio: `${w} / ${h}`, minHeight: toRem(48) }
: { height: toRem(fallbackHeight < 48 ? 48 : fallbackHeight) };
type MImageProps = { type MImageProps = {
content: IImageContent; content: IImageContent;
renderImageContent: (props: RenderImageContentProps) => ReactNode; renderImageContent: (props: RenderImageContentProps) => ReactNode;
@@ -432,11 +448,7 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
return ( return (
<Attachment outlined={outlined}> <Attachment outlined={outlined}>
<AttachmentBox <AttachmentBox style={attachmentMediaStyle(imgInfo?.w, imgInfo?.h, height)}>
style={{
height: toRem(height < 48 ? 48 : height),
}}
>
{renderImageContent({ {renderImageContent({
body: content.body || 'Image', body: content.body || 'Image',
info: imgInfo, info: imgInfo,
@@ -498,11 +510,7 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
} }
/> />
</AttachmentHeader> </AttachmentHeader>
<AttachmentBox <AttachmentBox style={attachmentMediaStyle(videoInfo.w, videoInfo.h, height)}>
style={{
height: toRem(height < 48 ? 48 : height),
}}
>
{renderVideoContent({ {renderVideoContent({
body: content.body || 'Video', body: content.body || 'Video',
info: videoInfo, info: videoInfo,
+5
View File
@@ -53,6 +53,11 @@ const NavItemBase = style({
color: OnContainer, color: OnContainer,
outline: 'none', outline: 'none',
minHeight: toRem(36), minHeight: toRem(36),
'@media': {
// The room/nav row is the app's primary tap target; give it a 44px touch
// area on phones (desktop stays the denser 36px).
'(max-width: 750px)': { minHeight: toRem(44) },
},
selectors: { selectors: {
'&:hover, &:focus-visible': { '&:hover, &:focus-visible': {
@@ -6,6 +6,11 @@ export const CardGrid = style({
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)', gridTemplateColumns: 'repeat(3, 1fr)',
gap: config.space.S400, gap: config.space.S400,
'@media': {
// Cards squish/overflow below ~360px each; drop to a single column on phones
// (mirrors the 750px breakpoint the nav uses).
'(max-width: 750px)': { gridTemplateColumns: '1fr' },
},
}); });
export const RoomCardBase = style([ export const RoomCardBase = style([
@@ -81,6 +81,10 @@ export const SidebarItem = recipe({
justifyContent: 'center', justifyContent: 'center',
position: 'relative', position: 'relative',
transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)', transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
'@media': {
// Space-rail buttons to a 44px touch target on phones (2px larger).
'(max-width: 750px)': { minWidth: toRem(44), minHeight: toRem(44) },
},
selectors: { selectors: {
'&:hover': { '&:hover': {
@@ -275,6 +275,7 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
key={key} key={key}
alignItems="Center" alignItems="Center"
gap="200" gap="200"
wrap="Wrap"
style={{ style={{
padding: config.space.S200, padding: config.space.S200,
borderRadius: config.radii.R400, borderRadius: config.radii.R400,
@@ -4,7 +4,9 @@ import { DefaultReset, color, config, toRem } from 'folds';
export const UrlPreview = style([ export const UrlPreview = style([
DefaultReset, DefaultReset,
{ {
width: toRem(400), // 25rem (=400px) on desktop, but shrink to fit narrow phones so a single
// card doesn't exceed the viewport (mirrors UrlPreviewWide's min()).
width: 'min(25rem, 92vw)',
minHeight: toRem(102), minHeight: toRem(102),
backgroundColor: color.SurfaceVariant.Container, backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer, color: color.SurfaceVariant.OnContainer,
+6 -1
View File
@@ -17,6 +17,7 @@ import { UserAvatar } from '../user-avatar';
import colorMXID from '../../../util/colorMXID'; import colorMXID from '../../../util/colorMXID';
import { getMxIdLocalPart } from '../../utils/matrix'; import { getMxIdLocalPart } from '../../utils/matrix';
import { BreakWord, LineClamp2, LineClamp3 } from '../../styles/Text.css'; import { BreakWord, LineClamp2, LineClamp3 } from '../../styles/Text.css';
import { ModalMobileFull } from '../../styles/Modal.css';
import { UserPresence } from '../../hooks/useUserPresence'; import { UserPresence } from '../../hooks/useUserPresence';
import { AvatarPresence, PresenceBadge } from '../presence'; import { AvatarPresence, PresenceBadge } from '../presence';
import { AvatarDecoration } from '../avatar-decoration/AvatarDecoration'; import { AvatarDecoration } from '../avatar-decoration/AvatarDecoration';
@@ -83,7 +84,11 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
escapeDeactivates: stopPropagation, escapeDeactivates: stopPropagation,
}} }}
> >
<Modal size="500" onContextMenu={(evt: any) => evt.stopPropagation()}> <Modal
size="500"
className={ModalMobileFull}
onContextMenu={(evt: any) => evt.stopPropagation()}
>
<ImageViewer <ImageViewer
src={viewAvatar} src={viewAvatar}
alt={userId} alt={userId}
+14 -2
View File
@@ -35,6 +35,7 @@ import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { callEmbedAtom } from '../../state/callEmbed'; import { callEmbedAtom } from '../../state/callEmbed';
import { useResizeObserver } from '../../hooks/useResizeObserver'; import { useResizeObserver } from '../../hooks/useResizeObserver';
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useCallEmbedRef } from '../../hooks/useCallEmbed'; import { useCallEmbedRef } from '../../hooks/useCallEmbed';
@@ -51,18 +52,25 @@ export function CallControls({ callEmbed }: CallControlsProps) {
const controlRef = useRef<HTMLDivElement>(null); const controlRef = useRef<HTMLDivElement>(null);
const callEmbedRef = useCallEmbedRef(); const callEmbedRef = useCallEmbedRef();
const setCallEmbed = useSetAtom(callEmbedAtom); const setCallEmbed = useSetAtom(callEmbedAtom);
const [compact, setCompact] = useState(document.body.clientWidth < 500); const screenSize = useScreenSize();
const [narrowContainer, setNarrowContainer] = useState(document.body.clientWidth < 500);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
useResizeObserver( useResizeObserver(
useCallback(() => { useCallback(() => {
const element = controlRef.current; const element = controlRef.current;
if (!element) return; if (!element) return;
setCompact(element.clientWidth < 500); setNarrowContainer(element.clientWidth < 500);
}, []), }, []),
useCallback(() => controlRef.current, []), useCallback(() => controlRef.current, []),
); );
// Collapse to the stacked/compact layout whenever the bar's own container is
// narrow (a small desktop call window) OR the viewport is a phone. The old
// element-only `< 500` check left the ~11-control row overflowing off-screen
// in the 500750px band (landscape phones / small tablets).
const compact = narrowContainer || screenSize === ScreenSize.Mobile;
useEffect(() => { useEffect(() => {
const onFullscreenChange = () => setIsFullscreen(!!document.fullscreenElement); const onFullscreenChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onFullscreenChange); document.addEventListener('fullscreenchange', onFullscreenChange);
@@ -330,6 +338,9 @@ export function CallControls({ callEmbed }: CallControlsProps) {
padding: '1rem 1.25rem', padding: '1rem 1.25rem',
zIndex: 100, zIndex: 100,
minWidth: '260px', minWidth: '260px',
// Don't run past the screen edges on a narrow phone (centered via
// translateX(-50%)); clamp to the viewport minus a small margin.
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
boxShadow: '0 8px 32px rgba(0,0,0,0.35)', boxShadow: '0 8px 32px rgba(0,0,0,0.35)',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@@ -376,6 +387,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
radii="500" radii="500"
alignItems="Center" alignItems="Center"
justifyContent="SpaceBetween" justifyContent="SpaceBetween"
wrap="Wrap"
> >
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}> <Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200"> <Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
+6 -1
View File
@@ -135,7 +135,12 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
escapeDeactivates: stopPropagation, escapeDeactivates: stopPropagation,
}} }}
> >
<Menu style={{ maxWidth: manage ? toRem(420) : toRem(340), maxHeight: '70vh' }}> <Menu
style={{
maxWidth: `min(${manage ? toRem(420) : toRem(340)}, calc(100vw - 2 * ${config.space.S400}))`,
maxHeight: '70vh',
}}
>
<Box direction="Column" style={{ maxHeight: '70vh' }}> <Box direction="Column" style={{ maxHeight: '70vh' }}>
<Box <Box
shrink="No" shrink="No"
@@ -119,7 +119,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
return ( return (
<Box onSubmit={handleSubmit} as="form" direction="Column" gap="400"> <Box onSubmit={handleSubmit} as="form" direction="Column" gap="400">
<Box direction="Column" gap="300"> <Box direction="Column" gap="300">
<Box gap="200"> <Box gap="200" wrap="Wrap">
<Box shrink="No" direction="Column" gap="100"> <Box shrink="No" direction="Column" gap="100">
<Text size="L400">Color</Text> <Text size="L400">Color</Text>
<Box gap="200"> <Box gap="200">
@@ -307,7 +307,7 @@ export function PolicyListViewer({ requestClose }: PolicyListViewerProps) {
gap="300" gap="300"
> >
{/* Tabs */} {/* Tabs */}
<Box gap="200"> <Box gap="200" wrap="Wrap">
<TabButton <TabButton
label="Users" label="Users"
count={userEntries.length} count={userEntries.length}
+5 -1
View File
@@ -190,6 +190,7 @@ function LightboxMedia({
pan, pan,
cursor, cursor,
onMouseDown, onMouseDown,
onTouchStart,
onImageDoubleClick, onImageDoubleClick,
}: { }: {
item: LightboxItem; item: LightboxItem;
@@ -198,6 +199,7 @@ function LightboxMedia({
pan: Pan; pan: Pan;
cursor: string; cursor: string;
onMouseDown: React.MouseEventHandler<HTMLElement>; onMouseDown: React.MouseEventHandler<HTMLElement>;
onTouchStart: React.TouchEventHandler<HTMLElement>;
onImageDoubleClick: () => void; onImageDoubleClick: () => void;
}) { }) {
const mx = useMatrixClient(); const mx = useMatrixClient();
@@ -253,6 +255,7 @@ function LightboxMedia({
alt={item.body} alt={item.body}
draggable={false} draggable={false}
onMouseDown={onMouseDown} onMouseDown={onMouseDown}
onTouchStart={onTouchStart}
onDoubleClick={onImageDoubleClick} onDoubleClick={onImageDoubleClick}
style={{ style={{
maxWidth: '100%', maxWidth: '100%',
@@ -297,7 +300,7 @@ function Lightbox({
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2); const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
// Pan is only active for a zoomed-in image; usePan resets its offset when this // Pan is only active for a zoomed-in image; usePan resets its offset when this
// flips false (i.e. back to 1x, on navigation, or on a video). // flips false (i.e. back to 1x, on navigation, or on a video).
const { pan, cursor, onMouseDown } = usePan(isImage && zoom !== 1); const { pan, cursor, onMouseDown, onTouchStart } = usePan(isImage && zoom !== 1);
const toggleZoom = useCallback(() => setZoom((z) => (z === 1 ? 2 : 1)), [setZoom]); const toggleZoom = useCallback(() => setZoom((z) => (z === 1 ? 2 : 1)), [setZoom]);
// Reset zoom when navigating to another item (and thus pan, via usePan). // Reset zoom when navigating to another item (and thus pan, via usePan).
@@ -512,6 +515,7 @@ function Lightbox({
pan={pan} pan={pan}
cursor={cursor} cursor={cursor}
onMouseDown={onMouseDown} onMouseDown={onMouseDown}
onTouchStart={onTouchStart}
onImageDoubleClick={toggleZoom} onImageDoubleClick={toggleZoom}
/> />
</Box> </Box>
+3 -1
View File
@@ -21,6 +21,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomLatestRenderedEvent } from '../../hooks/useRoomLatestRenderedEvent'; import { useRoomLatestRenderedEvent } from '../../hooks/useRoomLatestRenderedEvent';
import { useRoomEventReaders } from '../../hooks/useRoomEventReaders'; import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
import { EventReaders } from '../../components/event-readers'; import { EventReaders } from '../../components/event-readers';
import { useModalStyle } from '../../hooks/useModalStyle';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
export function RoomViewFollowingPlaceholder() { export function RoomViewFollowingPlaceholder() {
@@ -34,6 +35,7 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
({ className, room, ...props }, ref) => { ({ className, room, ...props }, ref) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const modalStyle = useModalStyle(360);
const latestEvent = useRoomLatestRenderedEvent(room); const latestEvent = useRoomLatestRenderedEvent(room);
const latestEventReaders = useRoomEventReaders(room, latestEvent?.getId()); const latestEventReaders = useRoomEventReaders(room, latestEvent?.getId());
const names = latestEventReaders const names = latestEventReaders
@@ -55,7 +57,7 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
escapeDeactivates: stopPropagation, escapeDeactivates: stopPropagation,
}} }}
> >
<Modal variant="Surface" size="300"> <Modal variant="Surface" size="300" style={modalStyle}>
<EventReaders room={room} eventId={eventId} requestClose={() => setOpen(false)} /> <EventReaders room={room} eventId={eventId} requestClose={() => setOpen(false)} />
</Modal> </Modal>
</FocusTrap> </FocusTrap>
+5 -2
View File
@@ -65,6 +65,7 @@ import { MessageLayout, MessageSpacing } from '../../../state/settings';
import { msgTranslationActiveAtomFamily } from '../../../state/translation'; import { msgTranslationActiveAtomFamily } from '../../../state/translation';
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine'; import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useModalStyle } from '../../../hooks/useModalStyle';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji'; import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
import * as css from './styles.css'; import * as css from './styles.css';
import { MsgAppearClass, SendingSpinClass } from '../../../styles/Animations.css'; import { MsgAppearClass, SendingSpinClass } from '../../../styles/Animations.css';
@@ -260,6 +261,7 @@ export const MessageReadReceiptItem = as<
} }
>(({ room, eventId, onClose, ...props }, ref) => { >(({ room, eventId, onClose, ...props }, ref) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const modalStyle = useModalStyle(360);
const handleClose = () => { const handleClose = () => {
setOpen(false); setOpen(false);
@@ -278,7 +280,7 @@ export const MessageReadReceiptItem = as<
escapeDeactivates: stopPropagation, escapeDeactivates: stopPropagation,
}} }}
> >
<Modal variant="Surface" size="300"> <Modal variant="Surface" size="300" style={modalStyle}>
<EventReaders room={room} eventId={eventId} requestClose={handleClose} /> <EventReaders room={room} eventId={eventId} requestClose={handleClose} />
</Modal> </Modal>
</FocusTrap> </FocusTrap>
@@ -674,6 +676,7 @@ export const MessageReportItem = as<
>(({ room, mEvent, onClose, ...props }, ref) => { >(({ room, mEvent, onClose, ...props }, ref) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const modalStyle = useModalStyle(480);
const [reportState, reportMessage] = useAsyncCallback( const [reportState, reportMessage] = useAsyncCallback(
useCallback( useCallback(
(eventId: string, score: number, reason: string) => (eventId: string, score: number, reason: string) =>
@@ -715,7 +718,7 @@ export const MessageReportItem = as<
escapeDeactivates: stopPropagation, escapeDeactivates: stopPropagation,
}} }}
> >
<Dialog variant="Surface"> <Dialog variant="Surface" style={modalStyle}>
<Header <Header
style={{ style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`, padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
+21 -25
View File
@@ -1898,11 +1898,13 @@ function Calls() {
/> />
</SequenceCard> </SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column"> <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile <SettingTile title="Ringtone Volume" description="Volume of the incoming call ringtone." />
title="Ringtone Volume" <Box
description="Volume of the incoming call ringtone." direction="Row"
after={ alignItems="Center"
<Box direction="Row" alignItems="Center" gap="200" style={{ minWidth: '160px' }}> gap="200"
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
>
<input <input
type="range" type="range"
min="0" min="0"
@@ -1917,8 +1919,6 @@ function Calls() {
{ringtoneVolume}% {ringtoneVolume}%
</Text> </Text>
</Box> </Box>
}
/>
</SequenceCard> </SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column"> <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile <SettingTile
@@ -1990,10 +1990,13 @@ function Calls() {
} }
/> />
{soundboardEnabled && ( {soundboardEnabled && (
<SettingTile <>
title="Soundboard Volume" <SettingTile title="Soundboard Volume" />
after={ <Box
<Box alignItems="Center" gap="200" style={{ minWidth: toRem(180) }}> alignItems="Center"
gap="200"
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
>
<input <input
type="range" type="range"
min={0} min={0}
@@ -2008,8 +2011,7 @@ function Calls() {
{soundboardVolume}% {soundboardVolume}%
</Text> </Text>
</Box> </Box>
} </>
/>
)} )}
</SequenceCard> </SequenceCard>
</Box> </Box>
@@ -2454,19 +2456,13 @@ function Messages() {
: 'On-device translation isnt available in this browser. Use a Chromium desktop browser (Chrome/Edge 138+) or the Lotus desktop app.' : 'On-device translation isnt available in this browser. Use a Chromium desktop browser (Chrome/Edge 138+) or the Lotus desktop app.'
} }
after={ after={
<select <SettingsSelect
aria-label="Translate messages into"
disabled={!translationSupported}
value={selectedTargetLang} value={selectedTargetLang}
onChange={(e) => setTranslateTargetLang(e.target.value)} onChange={(v) => setTranslateTargetLang(v)}
style={pickerInputStyle(color, config)} disabled={!translationSupported}
> aria-label="Translate messages into"
{TRANSLATE_TARGET_LANGUAGES.map((l) => ( options={TRANSLATE_TARGET_LANGUAGES.map((l) => ({ value: l.code, label: l.name }))}
<option key={l.code} value={l.code}> />
{l.name}
</option>
))}
</select>
} }
/> />
{translationSupported && ( {translationSupported && (
+13 -4
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useRef, CSSProperties } from 'react'; import React, { useEffect, useRef, CSSProperties } from 'react';
import { useAtomValue, useSetAtom } from 'jotai'; import { useAtomValue, useSetAtom } from 'jotai';
import { color, config, Icon, IconButton, Icons } from 'folds'; import { color, config, Icon, IconButton, Icons } from 'folds';
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
import { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast'; import { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
@@ -37,6 +38,7 @@ function ToastCard({ toast }: ToastCardProps) {
// folds tokens so toasts render correctly on stock Cinny themes (the --lt-* // folds tokens so toasts render correctly on stock Cinny themes (the --lt-*
// vars only exist while Terminal mode is active). // vars only exist while Terminal mode is active).
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal'); const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const isMobile = useScreenSize() === ScreenSize.Mobile;
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { useEffect(() => {
@@ -80,8 +82,11 @@ function ToastCard({ toast }: ToastCardProps) {
}`, }`,
borderRadius: config.radii.R400, borderRadius: config.radii.R400,
padding: `${config.space.S300} ${config.space.S400}`, padding: `${config.space.S300} ${config.space.S400}`,
minWidth: '280px', // Full-width on phones (the container spans the viewport there); a fixed
maxWidth: '340px', // 280-340px card would otherwise overflow a narrow screen.
minWidth: isMobile ? 0 : '280px',
maxWidth: isMobile ? 'none' : '340px',
width: isMobile ? '100%' : undefined,
boxShadow: lotusTerminal boxShadow: lotusTerminal
? toast.sticky ? toast.sticky
? 'var(--lt-box-glow-cyan)' ? 'var(--lt-box-glow-cyan)'
@@ -216,13 +221,17 @@ export function LotusToastContainer() {
}, []); }, []);
const toasts = useAtomValue(toastQueueAtom); const toasts = useAtomValue(toastQueueAtom);
const isMobile = useScreenSize() === ScreenSize.Mobile;
if (toasts.length === 0) return null; if (toasts.length === 0) return null;
const containerStyle: CSSProperties = { const containerStyle: CSSProperties = {
position: 'fixed', position: 'fixed',
bottom: '1.5rem', // Span the width just inside the screen edges on a phone (so full-width
right: '1.5rem', // cards fit); float bottom-right on desktop.
bottom: isMobile ? config.space.S200 : '1.5rem',
right: isMobile ? config.space.S200 : '1.5rem',
left: isMobile ? config.space.S200 : undefined,
zIndex: zIndices.toast, zIndex: zIndices.toast,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
+46 -1
View File
@@ -1,4 +1,4 @@
import { MouseEventHandler, useEffect, useRef, useState } from 'react'; import { MouseEventHandler, TouchEventHandler, useEffect, useRef, useState } from 'react';
export type Pan = { export type Pan = {
translateX: number; translateX: number;
@@ -21,6 +21,10 @@ export const usePan = (active: boolean) => {
const attachedRef = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>( const attachedRef = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
null, null,
); );
const touchAttachedRef = useRef<{
move: (e: TouchEvent) => void;
end: (e: TouchEvent) => void;
} | null>(null);
useEffect(() => { useEffect(() => {
setCursor(active ? 'grab' : 'initial'); setCursor(active ? 'grab' : 'initial');
@@ -53,6 +57,40 @@ export const usePan = (active: boolean) => {
document.addEventListener('mouseup', handleMouseUp); document.addEventListener('mouseup', handleMouseUp);
}; };
// Touch equivalent so a zoomed image can be dragged on a phone. Single-finger
// only (ignore multi-touch / pinch); touch events carry no movementX/Y, so we
// derive the delta from the previous touch position.
const handleTouchStart: TouchEventHandler<HTMLElement> = (evt) => {
if (!active || evt.touches.length !== 1) return;
setCursor('grabbing');
let lastX = evt.touches[0].clientX;
let lastY = evt.touches[0].clientY;
const handleTouchMove = (e: TouchEvent) => {
if (e.touches.length !== 1) return;
e.preventDefault();
const t = e.touches[0];
const dx = t.clientX - lastX;
const dy = t.clientY - lastY;
lastX = t.clientX;
lastY = t.clientY;
setPan((p) => ({ translateX: p.translateX + dx, translateY: p.translateY + dy }));
};
const handleTouchEnd = () => {
setCursor('grab');
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
document.removeEventListener('touchcancel', handleTouchEnd);
touchAttachedRef.current = null;
};
touchAttachedRef.current = { move: handleTouchMove, end: handleTouchEnd };
document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd);
document.addEventListener('touchcancel', handleTouchEnd);
};
useEffect(() => { useEffect(() => {
if (!active) setPan(INITIAL_PAN); if (!active) setPan(INITIAL_PAN);
}, [active]); }, [active]);
@@ -65,6 +103,12 @@ export const usePan = (active: boolean) => {
document.removeEventListener('mouseup', attachedRef.current.up); document.removeEventListener('mouseup', attachedRef.current.up);
attachedRef.current = null; attachedRef.current = null;
} }
if (touchAttachedRef.current) {
document.removeEventListener('touchmove', touchAttachedRef.current.move);
document.removeEventListener('touchend', touchAttachedRef.current.end);
document.removeEventListener('touchcancel', touchAttachedRef.current.end);
touchAttachedRef.current = null;
}
}, },
[], [],
); );
@@ -73,5 +117,6 @@ export const usePan = (active: boolean) => {
pan, pan,
cursor, cursor,
onMouseDown: handleMouseDown, onMouseDown: handleMouseDown,
onTouchStart: handleTouchStart,
}; };
}; };
@@ -455,6 +455,17 @@ export const getReactCustomHtmlParser = (
return <CodeBlock opts={opts}>{children}</CodeBlock>; return <CodeBlock opts={opts}>{children}</CodeBlock>;
} }
if (name === 'table') {
// Sanitize allows tables, but a wide one would otherwise overflow the
// message column and the page body on narrow screens. Wrap it in a
// horizontally-scrollable container (same idea as CodeBlock's Scroll).
return (
<div style={{ overflowX: 'auto', maxWidth: '100%' }}>
<table {...props}>{domToReact(children as unknown as DOMNode[], opts)}</table>
</div>
);
}
if (name === 'blockquote') { if (name === 'blockquote') {
return ( return (
<Text {...props} size="Inherit" as="blockquote" className={css.BlockQuote}> <Text {...props} size="Inherit" as="blockquote" className={css.BlockQuote}>
+21
View File
@@ -1,6 +1,27 @@
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
const mobileFullscreen = {
minWidth: '100vw',
minHeight: '100vh',
width: '100vw',
height: '100vh',
borderRadius: 0,
} as const;
export const ModalWide = style({ export const ModalWide = style({
minWidth: '85vw', minWidth: '85vw',
minHeight: '90vh', minHeight: '90vh',
'@media': {
// Fill the phone screen instead of floating as an 85vw card with margins.
'(max-width: 750px)': mobileFullscreen,
},
});
// Mobile-only full-screen: no desktop effect (keeps the modal's normal size),
// but fills the viewport on phones. For dialogs that should stay a small card on
// desktop but go edge-to-edge on mobile (e.g. the avatar viewer).
export const ModalMobileFull = style({
'@media': {
'(max-width: 750px)': mobileFullscreen,
},
}); });