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.
This commit is contained in:
Mohamed Boudra
2026-11-06 10:28:42 +07:00
committed by GitHub
parent 8b9c42c266
commit 6355b339c8
5 changed files with 67 additions and 27 deletions
@@ -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),
@@ -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.
+10 -2
View File
@@ -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}
<View style={styles.cancelButtonDisc}>{icon}</View>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
@@ -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",
+41 -24
View File
@@ -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 <Square size={buttonIconSize} color="white" fill="white" />;
return (
<View style={styles.voiceButtonRecordingDisc}>
<Square size={buttonIconSize} color="white" fill="white" />
</View>
);
}
const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
if (isMutedRealtime) {
@@ -837,12 +842,14 @@ function SendButtonTooltip({
testID={submitButtonTestID}
style={sendButtonCombinedStyle}
>
<SendButtonContent
isSubmitLoading={isSubmitLoading}
submitIcon={submitIcon}
submitLabel={submitLabel}
buttonIconSize={buttonIconSize}
/>
<View style={submitLabel ? styles.sendButtonDiscLabeled : styles.sendButtonDisc}>
<SendButtonContent
isSubmitLoading={isSubmitLoading}
submitIcon={submitIcon}
submitLabel={submitLabel}
buttonIconSize={buttonIconSize}
/>
</View>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<SendTooltipBody label={sendTooltipLabel} sendKeys={sendKeys} />
@@ -1925,7 +1932,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
styles.voiceButton,
Boolean(hovered) && !isDictating && styles.iconButtonHovered,
!isDictationStartEnabled && styles.buttonDisabled,
isDictating && styles.voiceButtonRecording,
],
[isDictating, isDictationStartEnabled],
);
@@ -1974,12 +1980,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
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,