From fc92809eb91192b2f238c6e210840c17108295cc Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 05:40:21 +0000 Subject: [PATCH 1/3] Phone: 44 px hit areas for the chat path's remaining icon buttons; pin variant, import and ::after content MessageEditor, RoomNavItem, MembersDrawer, ReactionViewer, RoomPinMenu, RoomViewTyping, CallChatView and JumpToTime get PhoneHit (alone) or PhoneHitSpread32 (two neighbours in a row). phoneTapTargets.test.ts now lists them, plus PasswordInput, Desk, SkyStateControl and PasswordLoginForm, which had no test, checks each class is imported from styles/PhoneHit.css, and pins the ::after content (a mutant without it passed before). Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room-nav/RoomNavItem.tsx | 3 +++ src/app/features/room/CallChatView.tsx | 8 +++++- src/app/features/room/MembersDrawer.tsx | 3 +++ src/app/features/room/RoomViewTyping.tsx | 9 ++++++- .../features/room/jump-to-time/JumpToTime.tsx | 3 ++- .../features/room/message/MessageEditor.tsx | 3 +++ .../room/reaction-viewer/ReactionViewer.tsx | 3 ++- .../room/room-pin-menu/RoomPinMenu.tsx | 4 ++- src/app/phoneTapTargets.test.ts | 26 +++++++++++++++++-- 9 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index e8a349f0..842d7146 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -66,6 +66,7 @@ import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo'; import { livekitSupport } from '../../hooks/useLivekitSupport'; import { StateEvent } from '../../../types/matrix/room'; import { webRTCSupported } from '../../utils/rtc'; +import { PhoneHitSpread32 } from '../../styles/PhoneHit.css'; type RoomNavItemMenuProps = { room: Room; @@ -229,6 +230,7 @@ function CallChatToggle() { return ( setChat(!chat)} aria-pressed={chat} aria-label="Toggle Chat" @@ -436,6 +438,7 @@ export function RoomNavItem({ } > {(triggerRef) => ( - + )} diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx index d9205076..3162eff0 100644 --- a/src/app/features/room/MembersDrawer.tsx +++ b/src/app/features/room/MembersDrawer.tsx @@ -59,6 +59,7 @@ import { useSpaceOptionally } from '../../hooks/useSpace'; import { ContainerColor } from '../../styles/ContainerColor.css'; import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag'; import { useRoomCreators } from '../../hooks/useRoomCreators'; +import { PhoneHit } from '../../styles/PhoneHit.css'; type MemberDrawerHeaderProps = { room: Room; @@ -87,6 +88,7 @@ function MemberDrawerHeader({ room }: MemberDrawerHeaderProps) { > {(triggerRef) => ( setPeopleDrawer(false)} @@ -359,6 +361,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) { virtualizer.scrollToOffset(0)} variant="Surface" radii="Pill" diff --git a/src/app/features/room/RoomViewTyping.tsx b/src/app/features/room/RoomViewTyping.tsx index 1142a3a8..9e0266e9 100644 --- a/src/app/features/room/RoomViewTyping.tsx +++ b/src/app/features/room/RoomViewTyping.tsx @@ -10,6 +10,7 @@ import { getMxIdLocalPart } from '../../utils/matrix'; import * as css from './RoomViewTyping.css'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers'; +import { PhoneHit } from '../../styles/PhoneHit.css'; export type RoomViewTypingProps = { room: Room; @@ -111,7 +112,13 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>( )} - + diff --git a/src/app/features/room/jump-to-time/JumpToTime.tsx b/src/app/features/room/jump-to-time/JumpToTime.tsx index 223c6cf6..4b231856 100644 --- a/src/app/features/room/jump-to-time/JumpToTime.tsx +++ b/src/app/features/room/jump-to-time/JumpToTime.tsx @@ -31,6 +31,7 @@ import { getToday, getYesterday, timeDayMonthYear, timeHourMinute } from '../../ import { DatePicker, TimePicker } from '../../../components/time-date'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type JumpToTimeProps = { onCancel: () => void; @@ -108,7 +109,7 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) { Jump to Time - + diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx index 2d7f2097..45d79eb7 100644 --- a/src/app/features/room/message/MessageEditor.tsx +++ b/src/app/features/room/message/MessageEditor.tsx @@ -54,6 +54,7 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { getEditedEvent, getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room'; import { mobileOrTablet } from '../../../utils/user-agent'; import { useComposingCheck } from '../../../hooks/useComposingCheck'; +import { PhoneHitSpread32 } from '../../../styles/PhoneHit.css'; type MessageEditorProps = { roomId: string; @@ -289,6 +290,7 @@ export const MessageEditor = as<'div', MessageEditorProps>( ( } > diff --git a/src/app/features/room/reaction-viewer/ReactionViewer.tsx b/src/app/features/room/reaction-viewer/ReactionViewer.tsx index 6c686bc5..b1a674d9 100644 --- a/src/app/features/room/reaction-viewer/ReactionViewer.tsx +++ b/src/app/features/room/reaction-viewer/ReactionViewer.tsx @@ -28,6 +28,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile'; import { useSpaceOptionally } from '../../../hooks/useSpace'; import { getMouseEventCords } from '../../../utils/dom'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; export type ReactionViewerProps = { room: Room; @@ -100,7 +101,7 @@ export const ReactionViewer = as<'div', ReactionViewerProps>( {`Reacted with :${selectedShortcode}:`} - + diff --git a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx index 9986849f..53f8d073 100644 --- a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx +++ b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx @@ -86,6 +86,7 @@ import { useGetMemberPowerTag, } from '../../../hooks/useMemberPowerTag'; import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type PinnedMessageProps = { room: Room; @@ -146,6 +147,7 @@ function PinnedMessage({ {canPinEvent && ( ( Pinned Messages - + diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts index d4e31772..be4bceb6 100644 --- a/src/app/phoneTapTargets.test.ts +++ b/src/app/phoneTapTargets.test.ts @@ -126,6 +126,8 @@ describe('phone icon-button hit areas', () => { const phone = [...css.matchAll(/@media ([^{]+) \{\n([\s\S]*?\n)\}(?:\n|$)/g)]; expect(phone.map((m) => m[1])).toEqual([PHONE]); expect(css).toMatch(/::after \{[^}]*width: max\(100%, 44px\);[^}]*height: max\(100%, 44px\);/); + // no content, no box: the pseudo-element is not rendered at all + expect(css).toMatch(/::after \{[^}]*content: "";/); expect(css).toMatch(/::after \{[^}]*position: absolute;/); // centred on the button: top and left at half, pulled back by half its own size expect(css).toMatch(/::after \{[^}]*top: 50%;/); @@ -190,13 +192,33 @@ describe('icon buttons in the room view', () => { ], 'features/room/RoomInput.tsx': Array(6).fill('PhoneHitSpread32'), 'features/room/RoomViewHeader.tsx': Array(6).fill('PhoneHitSpread40'), + // The chat path's other buttons. Two neighbours in a row: Spread32. One on its own: the plain PhoneHit. + 'features/room/message/MessageEditor.tsx': ['PhoneHitSpread32', 'PhoneHitSpread32'], // toolbar, emoji + 'features/room-nav/RoomNavItem.tsx': ['PhoneHitSpread32', 'PhoneHitSpread32'], // chat toggle, options + 'features/room/MembersDrawer.tsx': ['PhoneHit', 'PhoneHit'], // close, scroll to top + 'features/room/reaction-viewer/ReactionViewer.tsx': ['PhoneHit'], + 'features/room/room-pin-menu/RoomPinMenu.tsx': ['PhoneHit', 'PhoneHit'], // unpin, close + 'features/room/RoomViewTyping.tsx': ['PhoneHit'], + 'features/room/CallChatView.tsx': ['PhoneHit'], + 'features/room/jump-to-time/JumpToTime.tsx': ['PhoneHit'], + // Buttons that were fixed with #125 and had no test. + 'components/password-input/PasswordInput.tsx': ['PhoneHit'], + 'features/desk/Desk.tsx': ['PhoneHit'], + 'features/scene/SkyStateControl.tsx': ['PhoneHit'], + 'pages/auth/login/PasswordLoginForm.tsx': ['PhoneHit'], }; it.each(Object.entries(expected))( '%s: each IconButton carries its own PhoneHit class', (file, want) => { - const tags = iconButtonTags(readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8')); - expect(tags.map((t) => t.match(/\bPhoneHit\w+/)?.[0] ?? null)).toEqual(want); + const src = readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8'); + expect(iconButtonTags(src).map((t) => t.match(/\bPhoneHit\w*/)?.[0] ?? null)).toEqual(want); + // the name is the one PhoneHit.css.ts exports, not a local of the same name + [...new Set(want)].forEach((name) => + expect(src).toMatch( + new RegExp(`import \\{[^}]*\\b${name}\\b[^}]*\\} from '(\\.\\./)+styles/PhoneHit\\.css'`) + ) + ); } ); }); -- 2.47.3 From 446306b1e72b8c5103c04c7ffc54df4266650008 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 05:46:21 +0000 Subject: [PATCH 2/3] PhoneHit: Spread24 for the nav row's 24 px buttons, Spread26 for the unpin button; tests pin position, ::after declarations, className and import alias Soul pass on fc92809e: RoomNavItem's buttons are 24 px, not 32 (36 px pitch, hit areas overlapped); the unpin button is 26 px beside the Open chip. Six mutants survived (position: relative, pointer-events, z-index, title for className, aliased import); all now killed. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room-nav/RoomNavItem.tsx | 6 ++--- .../room/room-pin-menu/RoomPinMenu.tsx | 4 ++-- src/app/phoneTapTargets.test.ts | 24 +++++++++++++++---- src/app/styles/PhoneHit.css.ts | 3 ++- 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 842d7146..8d53ce7f 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -66,7 +66,7 @@ import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo'; import { livekitSupport } from '../../hooks/useLivekitSupport'; import { StateEvent } from '../../../types/matrix/room'; import { webRTCSupported } from '../../utils/rtc'; -import { PhoneHitSpread32 } from '../../styles/PhoneHit.css'; +import { PhoneHitSpread24 } from '../../styles/PhoneHit.css'; type RoomNavItemMenuProps = { room: Room; @@ -230,7 +230,7 @@ function CallChatToggle() { return ( setChat(!chat)} aria-pressed={chat} aria-label="Toggle Chat" @@ -438,7 +438,7 @@ export function RoomNavItem({ } > {canPinEvent && ( { expect(css).toMatch(/::after \{[^}]*top: 50%;/); expect(css).toMatch(/::after \{[^}]*left: 50%;/); expect(css).toMatch(/::after \{[^}]*transform: translate\(-50%, -50%\);/); + // the box anchors to its own button, receives the tap, and sits above nothing it should not + expect(css).toMatch(/@media [^{]+ \{\n\s*\.PhoneHit_PhoneHit__\w+ \{\n\s*position: relative;/); + const afterDecls = [...css.matchAll(/::after \{\n([^}]*)\}/g)].flatMap((m) => + m[1] + .split('\n') + .map((l) => l.trim().split(':')[0]) + .filter(Boolean) + ); + expect(afterDecls).not.toContain('pointer-events'); + expect(afterDecls).not.toContain('z-index'); expect(stripPhone(css).trim()).toBe(''); }); @@ -142,7 +152,7 @@ describe('phone icon-button hit areas', () => { rulesFor(css, selector, PHONE).map((d) => d['margin-inline']); // 32 px composer buttons, 40 px header buttons, 26 px message-option buttons (which also open vertically, // the menu around them clips a hit area drawn outside its box) - expect(margins(/PhoneHit_spread__/)).toEqual(expect.arrayContaining([9, 6, 2])); + expect(margins(/PhoneHit_spread__/)).toEqual(expect.arrayContaining([10, 9, 6, 2])); expect(rulesFor(css, /PhoneHit_PhoneHitRoom26__/, PHONE)).toEqual([{ 'margin-block': 9 }]); }); @@ -194,10 +204,10 @@ describe('icon buttons in the room view', () => { 'features/room/RoomViewHeader.tsx': Array(6).fill('PhoneHitSpread40'), // The chat path's other buttons. Two neighbours in a row: Spread32. One on its own: the plain PhoneHit. 'features/room/message/MessageEditor.tsx': ['PhoneHitSpread32', 'PhoneHitSpread32'], // toolbar, emoji - 'features/room-nav/RoomNavItem.tsx': ['PhoneHitSpread32', 'PhoneHitSpread32'], // chat toggle, options + 'features/room-nav/RoomNavItem.tsx': ['PhoneHitSpread24', 'PhoneHitSpread24'], // chat toggle, options: 16 px icon + 8 padding = 24 px, 0 gap 'features/room/MembersDrawer.tsx': ['PhoneHit', 'PhoneHit'], // close, scroll to top 'features/room/reaction-viewer/ReactionViewer.tsx': ['PhoneHit'], - 'features/room/room-pin-menu/RoomPinMenu.tsx': ['PhoneHit', 'PhoneHit'], // unpin, close + 'features/room/room-pin-menu/RoomPinMenu.tsx': ['PhoneHitSpread26', 'PhoneHit'], // unpin (26 px, 8 px from the Open chip), close 'features/room/RoomViewTyping.tsx': ['PhoneHit'], 'features/room/CallChatView.tsx': ['PhoneHit'], 'features/room/jump-to-time/JumpToTime.tsx': ['PhoneHit'], @@ -212,11 +222,15 @@ describe('icon buttons in the room view', () => { '%s: each IconButton carries its own PhoneHit class', (file, want) => { const src = readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8'); - expect(iconButtonTags(src).map((t) => t.match(/\bPhoneHit\w*/)?.[0] ?? null)).toEqual(want); + expect( + iconButtonTags(src).map((t) => t.match(/className=\{[^}]*?\b(PhoneHit\w*)\b/)?.[1] ?? null) + ).toEqual(want); // the name is the one PhoneHit.css.ts exports, not a local of the same name [...new Set(want)].forEach((name) => expect(src).toMatch( - new RegExp(`import \\{[^}]*\\b${name}\\b[^}]*\\} from '(\\.\\./)+styles/PhoneHit\\.css'`) + new RegExp( + `import \\{[^}]*\\b${name}\\b(?!\\s+as\\b)[^}]*\\} from '(\\.\\./)+styles/PhoneHit\\.css'` + ) ) ); } diff --git a/src/app/styles/PhoneHit.css.ts b/src/app/styles/PhoneHit.css.ts index 14ff375c..4ebc8370 100644 --- a/src/app/styles/PhoneHit.css.ts +++ b/src/app/styles/PhoneHit.css.ts @@ -33,7 +33,8 @@ export const PhoneHit = style({ const spread = (visual: number) => style([PhoneHit, { '@media': { [PHONE]: { marginInline: (PHONE_TAP - visual) / 2 } } }]); -const PhoneHitSpread26 = spread(26); +export const PhoneHitSpread24 = spread(24); +export const PhoneHitSpread26 = spread(26); export const PhoneHitSpread32 = spread(32); export const PhoneHitSpread40 = spread(40); -- 2.47.3 From 0c8474231e778da10b2c6cdb0a258e91fffba902 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 05:50:10 +0000 Subject: [PATCH 3/3] phoneTapTargets: each Spread class owns its margin, ::after declaration set is exact, no phone rule moves position, import must not be aliased Soul re-gate of 446306b1: swapped Spread24/26 definitions, display:none or overflow on the ::after, position:static on a Spread rule and an aliased import all passed; all killed now. Co-Authored-By: Claude Sonnet 5.5 --- src/app/phoneTapTargets.test.ts | 22 ++++++++++++++++++---- src/app/styles/PhoneHit.css.ts | 5 ++++- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts index cf7a6b27..a6459ade 100644 --- a/src/app/phoneTapTargets.test.ts +++ b/src/app/phoneTapTargets.test.ts @@ -141,8 +141,20 @@ describe('phone icon-button hit areas', () => { .map((l) => l.trim().split(':')[0]) .filter(Boolean) ); - expect(afterDecls).not.toContain('pointer-events'); - expect(afterDecls).not.toContain('z-index'); + expect([...afterDecls].sort()).toEqual([ + 'content', + 'height', + 'left', + 'position', + 'top', + 'transform', + 'width', + ]); + // nothing on a phone moves a button off its own anchor: only relative (the button) and absolute (the box) + expect([...css.matchAll(/position: (\w+);/g)].map((m) => m[1]).sort()).toEqual([ + 'absolute', + 'relative', + ]); expect(stripPhone(css).trim()).toBe(''); }); @@ -152,7 +164,9 @@ describe('phone icon-button hit areas', () => { rulesFor(css, selector, PHONE).map((d) => d['margin-inline']); // 32 px composer buttons, 40 px header buttons, 26 px message-option buttons (which also open vertically, // the menu around them clips a hit area drawn outside its box) - expect(margins(/PhoneHit_spread__/)).toEqual(expect.arrayContaining([10, 9, 6, 2])); + // each class owns its own margin: (44 - drawn size) / 2 + const own = (size: number) => margins(new RegExp(`PhoneHit_PhoneHitSpread${size}__`)); + expect([own(24), own(26), own(32), own(40)]).toEqual([[10], [9], [6], [2]]); expect(rulesFor(css, /PhoneHit_PhoneHitRoom26__/, PHONE)).toEqual([{ 'margin-block': 9 }]); }); @@ -229,7 +243,7 @@ describe('icon buttons in the room view', () => { [...new Set(want)].forEach((name) => expect(src).toMatch( new RegExp( - `import \\{[^}]*\\b${name}\\b(?!\\s+as\\b)[^}]*\\} from '(\\.\\./)+styles/PhoneHit\\.css'` + `import \\{(?:[^}]*,)?\\s*${name}\\s*(?:,[^}]*)?\\} from '(\\.\\./)+styles/PhoneHit\\.css'` ) ) ); diff --git a/src/app/styles/PhoneHit.css.ts b/src/app/styles/PhoneHit.css.ts index 4ebc8370..8403c126 100644 --- a/src/app/styles/PhoneHit.css.ts +++ b/src/app/styles/PhoneHit.css.ts @@ -31,7 +31,10 @@ export const PhoneHit = style({ /** An icon button in a row, `visual` px wide: the row opens up so its 44 px hit area meets its neighbours'. */ const spread = (visual: number) => - style([PhoneHit, { '@media': { [PHONE]: { marginInline: (PHONE_TAP - visual) / 2 } } }]); + style( + [PhoneHit, { '@media': { [PHONE]: { marginInline: (PHONE_TAP - visual) / 2 } } }], + `PhoneHitSpread${visual}` + ); export const PhoneHitSpread24 = spread(24); export const PhoneHitSpread26 = spread(26); -- 2.47.3