From 6355b339c8f7ea0f2b4d0f55d315d6a03d7ef434 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Nov 2026 10:28:42 +0700 Subject: [PATCH] fix(app): paint a smaller send and stop disc inside the touch hit frame (#8891) Under touch sizing the composer's send and stop buttons filled their whole 44pt hit frame with color, so they outweighed every unpainted control in the toolbar. The trigger keeps the 44pt frame and now paints a 36pt disc (TOOLBAR_DISC_SIZE) centered inside it. Pointer sizing paints 28 inside 28, so desktop is unchanged. The voice button's recording fill follows the same rule. --- docs/design.md | 2 +- .../components/ui/control-geometry.test.ts | 7 ++ .../app/src/components/ui/control-geometry.ts | 8 +++ packages/app/src/composer/index.tsx | 12 +++- packages/app/src/composer/input/input.tsx | 65 ++++++++++++------- 5 files changed, 67 insertions(+), 27 deletions(-) diff --git a/docs/design.md b/docs/design.md index b48ed81891..e840e81bfa 100644 --- a/docs/design.md +++ b/docs/design.md @@ -68,7 +68,7 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als `destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog. -Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`. +Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. The hit region is not the painted area: a filled toolbar control such as the composer's send or stop keeps the 44pt frame and paints a 36pt disc inside it (`TOOLBAR_DISC_SIZE`), so it does not outweigh its unpainted neighbours. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`. One gate, `resolveControlSizing`, picks touch or pointer sizing: touch when the layout is compact or the device has a touch screen. An iPad with the sidebar docked is wide and touch-sized; a desktop is pointer-sized; a phone is both compact and touch. The touch-screen fact is read once at startup (`hasTouchScreen` in `hooks/use-fine-pointer.ts`), so attaching a mouse or keyboard to a tablet never shrinks its controls. Stylesheets take sizes from `bySizing({ touch, pointer })`, which resolves to a breakpoint value, so a caller never sees which sizing it got. Rows, icon buttons and icon slots take theirs from `styles/density.ts`, which uses the same gate. An `{ xs, md }` literal for a hit target or glyph is wrong: on an iPad it yields the pointer size. diff --git a/packages/app/src/components/ui/control-geometry.test.ts b/packages/app/src/components/ui/control-geometry.test.ts index 90b18d6029..9b0ef21308 100644 --- a/packages/app/src/components/ui/control-geometry.test.ts +++ b/packages/app/src/components/ui/control-geometry.test.ts @@ -14,6 +14,8 @@ import { getControlInteractionPhase, TOOLBAR_CONTROL_HEIGHT, TOOLBAR_CONTROL_SIZES, + TOOLBAR_DISC_SIZE, + TOOLBAR_DISC_SIZES, TOUCH_TARGET, } from "@/components/ui/control-geometry"; import type { Theme } from "@/styles/theme"; @@ -90,6 +92,11 @@ describe("control sizing gate", () => { // Composer toolbar controls: a full target on touch, unchanged 28 on pointer. expect(TOOLBAR_CONTROL_SIZES).toEqual({ touch: TOUCH_TARGET, pointer: 28 }); expect(TOOLBAR_CONTROL_HEIGHT).toEqual({ xs: TOUCH_TARGET, md: 28 }); + // A filled toolbar control paints a 36pt disc inside its 44pt touch frame, and fills its + // 28pt pointer frame exactly, so desktop is unchanged. Its glyph is the `md` glyph. + expect(TOOLBAR_DISC_SIZES).toEqual({ touch: 36, pointer: TOOLBAR_CONTROL_SIZES.pointer }); + expect(TOOLBAR_DISC_SIZE).toEqual({ xs: 36, md: 28 }); + expect(iconSizeFor("md")).toEqual({ touch: 20, pointer: 16 }); for (const size of ["sm", "md"] as const) { expect(controlHeightForLayout(size, true)).toBeGreaterThan( controlHeightForLayout(size, false), diff --git a/packages/app/src/components/ui/control-geometry.ts b/packages/app/src/components/ui/control-geometry.ts index 407c042cbe..ae552a4c78 100644 --- a/packages/app/src/components/ui/control-geometry.ts +++ b/packages/app/src/components/ui/control-geometry.ts @@ -93,6 +93,14 @@ export const ICON_BUTTON_SIZES = { export const TOOLBAR_CONTROL_SIZES = { touch: TOUCH_TARGET, pointer: 28 } satisfies SizingValue; export const TOOLBAR_CONTROL_HEIGHT = bySizing(TOOLBAR_CONTROL_SIZES); +/** + * The painted disc of a filled toolbar control, such as the composer's send and stop. The + * control keeps the full toolbar hit frame and paints this smaller disc centered inside it, so + * touch-sized it does not outweigh its unpainted neighbours. Pointer-sized it fills the frame. + */ +export const TOOLBAR_DISC_SIZES = { touch: 36, pointer: 28 } satisfies SizingValue; +export const TOOLBAR_DISC_SIZE = bySizing(TOOLBAR_DISC_SIZES); + /** * Glyph sizes. Pointer glyphs sit beside 13-14pt text; touch glyphs sit beside iOS's 17pt body * text, so each step is larger. diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index ad6961fe16..5401f77d73 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -35,6 +35,7 @@ import { controlSizingForLayout, iconSizeFor, TOOLBAR_CONTROL_HEIGHT, + TOOLBAR_DISC_SIZE, } from "@/components/ui/control-geometry"; import { usePanelStore } from "@/stores/panel-store"; import { usePointerCapabilities } from "@/hooks/pointer-capabilities"; @@ -1117,7 +1118,7 @@ function ComposerCancelButton({ accessibilityRole="button" style={cancelButtonStyle} > - {icon} + {icon} @@ -2595,14 +2596,21 @@ const styles = StyleSheet.create((theme: Theme) => ({ width: "100%", gap: theme.spacing[3], }, + // The trigger is the hit frame and stays unpainted; the disc inside it carries the fill. cancelButton: { width: TOOLBAR_CONTROL_HEIGHT, height: TOOLBAR_CONTROL_HEIGHT, + alignItems: "center", + justifyContent: "center", + marginLeft: theme.spacing[1], + }, + cancelButtonDisc: { + width: TOOLBAR_DISC_SIZE, + height: TOOLBAR_DISC_SIZE, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.palette.red[600], alignItems: "center", justifyContent: "center", - marginLeft: theme.spacing[1], }, rightControls: { flexDirection: "row", diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index a71469c626..be1f2c3ed0 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -33,6 +33,7 @@ import { controlSizingForLayout, iconSizeFor, TOOLBAR_CONTROL_HEIGHT, + TOOLBAR_DISC_SIZE, } from "@/components/ui/control-geometry"; import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native"; import * as Haptics from "expo-haptics"; @@ -349,7 +350,11 @@ function VoiceButtonIcon({ buttonIconSize: number; }) { if (isDictating) { - return ; + return ( + + + + ); } const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping; if (isMutedRealtime) { @@ -837,12 +842,14 @@ function SendButtonTooltip({ testID={submitButtonTestID} style={sendButtonCombinedStyle} > - + + + @@ -1925,7 +1932,6 @@ export const MessageInput = forwardRef( styles.voiceButton, Boolean(hovered) && !isDictating && styles.iconButtonHovered, !isDictationStartEnabled && styles.buttonDisabled, - isDictating && styles.voiceButtonRecording, ], [isDictating, isDictationStartEnabled], ); @@ -1974,12 +1980,8 @@ export const MessageInput = forwardRef( allowEmpty: allowEmptySubmit, }); const sendButtonCombinedStyle = useMemo( - () => [ - styles.sendButton, - submitLabel ? styles.sendButtonLabeled : undefined, - isPresentedSendDisabled && styles.buttonDisabled, - ], - [isPresentedSendDisabled, submitLabel], + () => [styles.sendButton, isPresentedSendDisabled && styles.buttonDisabled], + [isPresentedSendDisabled], ); const overlayContainerStyle = useMemo( () => [styles.overlayContainer, { opacity: surfacePresentation.overlay.opacity }], @@ -2416,23 +2418,38 @@ const styles = StyleSheet.create((theme: Theme) => ({ alignItems: "center", justifyContent: "center", }, - voiceButtonRecording: { - backgroundColor: theme.colors.destructive, - }, - sendButton: { - width: TOOLBAR_CONTROL_HEIGHT, - height: TOOLBAR_CONTROL_HEIGHT, + voiceButtonRecordingDisc: { + width: TOOLBAR_DISC_SIZE, + height: TOOLBAR_DISC_SIZE, borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.accent, + backgroundColor: theme.colors.destructive, + alignItems: "center", + justifyContent: "center", + }, + // The trigger is the hit frame and stays unpainted; the disc inside it carries the fill. + sendButton: { + minWidth: TOOLBAR_CONTROL_HEIGHT, + height: TOOLBAR_CONTROL_HEIGHT, alignItems: "center", justifyContent: "center", marginLeft: theme.spacing[1], }, - sendButtonLabeled: { - width: "auto", - minWidth: TOOLBAR_CONTROL_HEIGHT, + sendButtonDisc: { + width: TOOLBAR_DISC_SIZE, + height: TOOLBAR_DISC_SIZE, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.accent, + alignItems: "center", + justifyContent: "center", + }, + sendButtonDiscLabeled: { + minWidth: TOOLBAR_DISC_SIZE, + height: TOOLBAR_DISC_SIZE, paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.accent, + alignItems: "center", + justifyContent: "center", }, sendButtonLabel: { fontSize: theme.fontSize.base,