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,