From 470b5217ae88b4956bc2a3c0e115c7616b9197d7 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 17 Sep 2026 01:14:48 -0400 Subject: [PATCH] fix(composer): one-row toolbar with uniform 32px buttons on every viewport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer looked off in two ways, both confirmed by rendering CustomEditor with RoomInput's exact props and measuring the buttons headlessly: Desktop: the Lotus additions (location, poll, voice, schedule) used `Icon size="100"` (18px) inside the same `IconButton size="300"` as the upstream Aa/sticker/emoji/send buttons (24px icons), so one row mixed 32×32, 26×26 and a 28×19 "GIF" text stub. Every button is now 32×32: the four small icons use the default icon size and the GIF label sits in a 1.5rem box, the same footprint as an icon. The mic's idle button in VoiceMessageRecorder gets the same treatment since it lives in this row. Phones: d6159997 let the before|editable|after row flex-wrap at <=750px, but folds' Scroll (the editable's wrapper) is `width: 100%`, so the row ALWAYS broke into three stacked lines — "+" alone on top, the input flush against the left edge on its own line (the :first-child padding selectors no longer matched), and emoji/draft/send left-aligned underneath. e1bb8301's "+" overflow menu was meant to produce [ + | input | emoji | send ] but never could while the row wrapped. The row no longer wraps (upstream behaviour); instead the collapse into the "+" overflow is keyed on the viewport (ScreenSize.Mobile) as well as the touch UA, so a phone-width window on a desktop UA — iPad desktop mode, split-screen PWA, docked window — also collapses instead of rendering ten controls inline and clipping Send behind the editor's overflow:hidden. The "Draft saved" label moves into the overflow row in compact mode so the inline row stays [ + | input | emoji | count | send ]. The editable's vertical padding grows to 19px at phone width (only when the row actually has buttons) so the text sits level with the 44px touch targets instead of hugging the top of the row. Those touch targets now also apply the shared MobileTouchTarget class, matching the recorder button. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/components/VoiceMessageRecorder.tsx | 2 +- src/app/components/editor/Editor.css.ts | 38 ++++++--- src/app/features/room/RoomInput.tsx | 92 ++++++++++++++------- 3 files changed, 91 insertions(+), 41 deletions(-) diff --git a/src/app/components/VoiceMessageRecorder.tsx b/src/app/components/VoiceMessageRecorder.tsx index 737abf9d1..db56a7812 100644 --- a/src/app/components/VoiceMessageRecorder.tsx +++ b/src/app/components/VoiceMessageRecorder.tsx @@ -248,7 +248,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) { radii="300" title="Record voice message" > - + ); } diff --git a/src/app/components/editor/Editor.css.ts b/src/app/components/editor/Editor.css.ts index 6ab330f71..1a0ad90f1 100644 --- a/src/app/components/editor/Editor.css.ts +++ b/src/app/components/editor/Editor.css.ts @@ -16,21 +16,16 @@ export const EditorOptions = style([ DefaultReset, { 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. +// The composer's before | editable | after row. It must NOT wrap: folds' +// Scroll (the editable's wrapper) is `width: 100%`, so a wrapping row always +// breaks into three stacked lines (before / editable / after) — the "wonky" +// phone composer. Narrow viewports keep one row by collapsing the secondary +// buttons behind the "+" overflow instead (RoomInput `compact`). export const EditorInputRow = style({ - '@media': { - '(max-width: 750px)': { flexWrap: 'wrap' }, - }, + minWidth: 0, }); export const EditorTextareaScroll = style({}); @@ -41,6 +36,20 @@ export const EditorTextarea = style([ flexGrow: 1, height: '100%', padding: `${toRem(13)} ${toRem(1)}`, + '@media': { + // Phone-width composer rows carry 44px touch targets (MobileTouchTarget), + // so the row is 60px instead of 48px; pad the text to keep it level with + // the buttons instead of hugging the top of the row. Only when the row + // actually has before/after buttons (not the edit-message editor). + '(max-width: 750px)': { + selectors: { + [`${EditorTextareaScroll}:not(:only-child) &`]: { + paddingTop: toRem(19), + paddingBottom: toRem(19), + }, + }, + }, + }, selectors: { [`${EditorTextareaScroll}:first-child &`]: { paddingLeft: toRem(13), @@ -70,6 +79,13 @@ export const EditorPlaceholderTextVisual = style([ display: 'block', paddingTop: toRem(13), paddingLeft: toRem(1), + '@media': { + '(max-width: 750px)': { + selectors: { + [`${EditorTextareaScroll}:not(:only-child) &`]: { paddingTop: toRem(19) }, + }, + }, + }, }, ]); diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 84c92abc6..6e4f36403 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -129,6 +129,8 @@ import { useTheme } from '../../hooks/useTheme'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { useComposingCheck } from '../../hooks/useComposingCheck'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { MobileTouchTarget } from '../../styles/mobile.css'; import { VoiceMessageRecorder } from '../../components/VoiceMessageRecorder'; import { PollCreator } from './PollCreator'; import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationStatus'; @@ -235,11 +237,20 @@ export const RoomInput = forwardRef( const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar'); const [composerToolbarButtons] = useSetting(settingsAtom, 'composerToolbarButtons'); const isMobile = mobileOrTablet(); - // On phones the composer's secondary action buttons (attach, GIF, poll, + const screenSize = useScreenSizeContext(); + // Compact composer: the secondary action buttons (attach, GIF, poll, // location, voice, formatting, schedule) collapse behind a "+" toggle so the - // input stays one compact row instead of wrapping into a tall stack. Emoji + - // Send remain inline. Desktop keeps everything inline (isMobile === false). + // input stays ONE row — [ + | input | emoji | send ]. Keyed on the viewport, + // not only the user agent: a phone-width window on a desktop UA (iPad in + // desktop mode, a split-screen PWA, a docked window) otherwise renders all + // ten controls inline and clips the Send button behind the editor's + // overflow:hidden. Touch-sized (44px) targets stay UA-gated: a narrow + // desktop window still has a mouse. + const compact = isMobile || screenSize === ScreenSize.Mobile; const [mobileToolsOpen, setMobileToolsOpen] = useState(false); + // Both gates on purpose: the class covers phone-width viewports (matches + // VoiceMessageRecorder's idle button, which lives in the same row), the + // inline style covers touch UAs with a wider viewport (tablet landscape). const touchTarget = isMobile ? { minWidth: '44px', minHeight: '44px' } : undefined; const showFormat = composerToolbarButtons?.showFormat ?? true; const showEmoji = composerToolbarButtons?.showEmoji ?? true; @@ -1140,7 +1151,7 @@ export const RoomInput = forwardRef( ) } before={ - isMobile ? ( + compact ? ( setMobileToolsOpen((open) => !open)} aria-label="More actions" @@ -1150,6 +1161,7 @@ export const RoomInput = forwardRef( size="300" radii="300" style={touchTarget} + className={MobileTouchTarget} > @@ -1161,6 +1173,7 @@ export const RoomInput = forwardRef( size="300" radii="300" style={touchTarget} + className={MobileTouchTarget} > @@ -1174,6 +1187,7 @@ export const RoomInput = forwardRef( size="300" radii="300" style={touchTarget} + className={MobileTouchTarget} aria-label={toolbar ? 'Hide formatting toolbar' : 'Show formatting toolbar'} aria-pressed={toolbar} onClick={() => setToolbar(!toolbar)} @@ -1200,6 +1214,7 @@ export const RoomInput = forwardRef( size="300" radii="300" style={touchTarget} + className={MobileTouchTarget} > ( size="300" radii="300" style={touchTarget} + className={MobileTouchTarget} > ( radii="300" disabled={gifUploading} style={touchTarget} + className={MobileTouchTarget} > - {gifUploading ? ( - - ) : ( - - GIF - - )} + {/* Sized like a default Icon (1.5rem) so the button + matches its 32px neighbours instead of a 28×19 stub. */} + + {gifUploading ? ( + + ) : ( + + GIF + + )} + )} @@ -1340,11 +1366,12 @@ export const RoomInput = forwardRef( radii="300" title="Share location" style={touchTarget} + className={MobileTouchTarget} > {locating ? ( - + ) : ( - + )} ) : null; @@ -1359,8 +1386,9 @@ export const RoomInput = forwardRef( radii="300" title="Create poll" style={touchTarget} + className={MobileTouchTarget} > - + ) : null; @@ -1383,10 +1411,11 @@ export const RoomInput = forwardRef( size="300" radii="300" style={touchTarget} + className={MobileTouchTarget} aria-label="Schedule message" title="Schedule message" > - + ) : null; @@ -1426,16 +1455,18 @@ export const RoomInput = forwardRef( } }); - // Mobile: keep only emoji/sticker inline beside Send; the rest move + // Compact: keep only emoji/sticker inline beside Send; the rest move // into the "+" overflow row (rendered via `bottom`), led by the attach - // button that `before` gives up on mobile. Desktop renders all inline. + // button that `before` gives up on, and trailed by the draft label so + // the inline row stays [ emoji | count | send ]. Wide viewports render + // everything inline. const emojiInline = orderedButtons.filter( (node) => React.isValidElement(node) && node.key === 'showEmojiSticker', ); const overflowButtons = orderedButtons.filter( (node) => !(React.isValidElement(node) && node.key === 'showEmojiSticker'), ); - if (isMobile) { + if (compact) { composerOverflow = ( <> ( size="300" radii="300" style={touchTarget} + className={MobileTouchTarget} > {overflowButtons} + ); } return ( <> - {isMobile ? emojiInline : orderedButtons} + {compact ? emojiInline : orderedButtons} {gifError && ( ( {locationError} )} - + {!compact && } {charCount > 0 && ( ( size="300" radii="300" style={touchTarget} + className={MobileTouchTarget} aria-label="Send message" > @@ -1514,7 +1548,7 @@ export const RoomInput = forwardRef( })()} bottom={ <> - {isMobile && mobileToolsOpen && composerOverflow && ( + {compact && mobileToolsOpen && composerOverflow && (