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,