From 6cfe903127209cd5a7f31302e1584169866e657d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 10 Nov 2026 08:33:58 +0700 Subject: [PATCH] Lay composer toolbar controls out at their full highlight on touch (#9238) Touch-sized, each toolbar control now lays out at its whole highlight with no gap, so neighbouring highlights touch instead of hanging into gaps through negative margins. The row's overhang puts the attach glyph and a leading label on the composer's rail, and the trailing disc keeps a small clearance from its neighbour. --- docs/design.md | 6 +-- .../components/combined-model-selector.tsx | 3 +- .../components/ui/control-geometry.test.ts | 13 ++--- .../app/src/components/ui/control-geometry.ts | 48 +++++-------------- .../src/composer/agent-controls/control.tsx | 6 +-- .../app/src/composer/agent-controls/index.tsx | 9 +--- .../composer/agent-controls/layout.test.ts | 9 ++-- .../app/src/composer/agent-controls/layout.ts | 19 +++----- .../composer/agent-controls/model-sheet.tsx | 5 +- packages/app/src/composer/index.tsx | 7 ++- packages/app/src/composer/input/input.tsx | 21 ++++---- .../src/composer/input/toolbar-rails.test.ts | 11 +++-- .../app/src/composer/input/toolbar-rails.ts | 35 +++++++------- 13 files changed, 77 insertions(+), 115 deletions(-) diff --git a/docs/design.md b/docs/design.md index 2d769e4f2d..f86565e97e 100644 --- a/docs/design.md +++ b/docs/design.md @@ -70,9 +70,9 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als 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()`. -Dense toolbar rows, such as the composer's, are the exception to the 44pt control. Touch-sized the row is 44pt tall and each control is a 36pt circle (`TOOLBAR_DISC_SIZE`): send and stop fill it, and every other control's pressed highlight fills it, so every highlight is the same fully rounded shape. Labelled pickers are 36pt pills. A control lays out at the width of what it paints (`toolbarSlot`, `toolbarContentSlot`) and its circle reaches into the gaps, so `TOOLBAR_CONTROL_GAP`, 20pt, is the visible space between glyphs. Toolbar glyphs match the header icons: 20pt, the app's 1.5px stroke, resting at `foreground` (`toolbarGlyphsFor`). The context ring is 18pt with a 2px stroke (`TOOLBAR_RINGS`): a circle fills its box where an outline glyph does not, and a thin circle reads lighter than a glyph at the same stroke. A disc's solid glyph, such as the stop square, stays at the `md` size (`TOOLBAR_DISC_GLYPH_SIZES`). Pointer-sized toolbars keep 28px controls, a 4px gap, and a 16pt muted glyph that brightens on hover. +Dense toolbar rows, such as the composer's, are the exception to the 44pt control. Touch-sized the row is 44pt tall and each control is a 36pt circle (`TOOLBAR_DISC_SIZE`): send and stop fill it, and every other control's pressed highlight fills it, so every highlight is the same fully rounded shape. Labelled pickers are 36pt pills. A control lays out at its full highlight (`toolbarSlot`, `toolbarContentSlot`) with no gap between controls (`TOOLBAR_CONTROL_GAP`), so neighbouring highlights touch and never overlap. Toolbar glyphs match the header icons: 20pt, the app's 1.5px stroke, resting at `foreground` (`toolbarGlyphsFor`). The context ring is 18pt with a 2px stroke (`TOOLBAR_RINGS`): a circle fills its box where an outline glyph does not, and a thin circle reads lighter than a glyph at the same stroke. A disc's solid glyph, such as the stop square, stays at the `md` size (`TOOLBAR_DISC_GLYPH_SIZES`). Pointer-sized toolbars keep 28px controls, a 4px gap, and a 16pt muted glyph that brightens on hover. -In the composer, the first glyph's ink starts on the text column's edge, and the trailing control is always a disc, so the row does not shift as it changes between voice mode, send and stop. The disc nests into the corner, as far from the right edge as from the bottom, and touch-sized the composer's corner radius is concentric with it (`packages/app/src/composer/input/toolbar-rails.ts`). +In the composer, the first glyph's ink starts on the text column's edge, and the trailing control is always a disc, so the row does not shift as it changes between voice mode, send and stop. The disc nests into the corner, as far from the right edge as from the bottom, and touch-sized the composer's corner radius is concentric with it (`packages/app/src/composer/input/toolbar-rails.ts`). Touch-sized the disc keeps a 6pt clearance from its neighbour, because it is always painted and a pressed neighbour would merge with it. The attach button sits 2pt closer to the picker than its highlight would put it: the plus is all thin arms, so the full gap reads wide. That offset is set by eye. 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. @@ -144,7 +144,7 @@ Things align to their glyphs, not to their boxes. A row's leading icon, its titl Pick the rails from the content, then hold them. A settings card establishes a leading rail at the icon's left edge and a trailing rail at the last glyph's right edge; every row in that card uses the same two. A row whose icon is absent still starts its title on the leading rail. Indentation is a new rail, not an arbitrary offset. -The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct. The composer toolbar is the worked example: its outermost ink, the attach `+` on the left and the trailing glyph or send/stop disc on the right, sits on the text column's edges, and the 44pt touch slots overhang into the composer padding by each end control's ink inset (`composerToolbarOverhang`). +The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct. The composer toolbar is the worked example: its outermost ink, the attach `+` on the left and the trailing glyph or send/stop disc on the right, sits on the text column's edges, and touch-sized the end highlights overhang into the composer padding by each end control's ink inset (`composerToolbarOverhang`). Optical alignment beats arithmetic when a glyph disagrees with its bounding box. Icons with visual weight on one side, chevrons, and single-character labels usually need a small nudge to look centered. Trust the eye, then leave a comment saying the offset is optical. diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index cfd33227a2..05b118b866 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -305,7 +305,7 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", backgroundColor: "transparent", gap: theme.spacing[1], - ...toolbarContentSlot(theme.spacing[2]), + ...toolbarContentSlot(), borderRadius: theme.borderRadius.full, }, triggerHovered: { @@ -338,7 +338,6 @@ const styles = StyleSheet.create((theme) => ({ customTriggerWrapper: { paddingHorizontal: 0, paddingVertical: 0, - marginHorizontal: 0, height: "auto", }, triggerFill: { diff --git a/packages/app/src/components/ui/control-geometry.test.ts b/packages/app/src/components/ui/control-geometry.test.ts index 431bb0434b..2e3352397c 100644 --- a/packages/app/src/components/ui/control-geometry.test.ts +++ b/packages/app/src/components/ui/control-geometry.test.ts @@ -119,15 +119,10 @@ describe("control sizing gate", () => { }); describe("toolbar glyphs and spacing", () => { - it("spaces touch toolbar controls by what they paint and keeps the pointer's 4px gap", () => { - expect(TOOLBAR_CONTROL_GAPS).toEqual({ touch: 20, pointer: 4 }); - expect(TOOLBAR_CONTROL_GAP).toEqual({ xs: 20, md: 4 }); - // A 20pt glyph's disc-sized frame reaches 8 into the gap on each side. - expect(toolbarSlot(20)).toEqual({ - width: TOOLBAR_DISC_SIZE, - height: TOOLBAR_DISC_SIZE, - marginHorizontal: { xs: -8, md: 0 }, - }); + it("lays touch toolbar controls out at their highlight with no gap and keeps the pointer's 4px gap", () => { + expect(TOOLBAR_CONTROL_GAPS).toEqual({ touch: 0, pointer: 4 }); + expect(TOOLBAR_CONTROL_GAP).toEqual({ xs: 0, md: 4 }); + expect(toolbarSlot()).toEqual({ width: TOOLBAR_DISC_SIZE, height: TOOLBAR_DISC_SIZE }); }); it("paints touch toolbar glyphs at the header icons' 20pt and 1.5px stroke and leaves pointer unchanged", () => { diff --git a/packages/app/src/components/ui/control-geometry.ts b/packages/app/src/components/ui/control-geometry.ts index 0ec75ad6ab..f8d3132e38 100644 --- a/packages/app/src/components/ui/control-geometry.ts +++ b/packages/app/src/components/ui/control-geometry.ts @@ -101,47 +101,23 @@ export const TOOLBAR_DISC_SIZES = { touch: 36, pointer: 28 } satisfies SizingVal export const TOOLBAR_DISC_SIZE = bySizing(TOOLBAR_DISC_SIZES); /** - * The space between adjacent toolbar controls. Touch-sized it is the visible space between what - * the controls paint, because each control lays out at its painted width (`toolbarSlot`). + * The space between adjacent toolbar controls. Touch-sized there is none: each control lays out at + * its fully rounded highlight, so neighbouring highlights touch and never overlap. */ -export const TOOLBAR_CONTROL_GAPS = { touch: 20, pointer: 4 } satisfies SizingValue; +export const TOOLBAR_CONTROL_GAPS = { touch: 0, pointer: 4 } satisfies SizingValue; export const TOOLBAR_CONTROL_GAP = bySizing(TOOLBAR_CONTROL_GAPS); -/** - * A toolbar control's frame: a disc-sized circle, which is also its fully rounded highlight. - * Touch-sized it lays out at the width of the glyph it paints, `shapeWidth`, and the circle reaches - * into the gaps on either side, so the row spaces what you see. Pointer-sized it is the square - * control. - */ -export function toolbarSlot(shapeWidth: number): { - width: LayoutValue; - height: LayoutValue; - marginHorizontal: LayoutValue; -} { - return { - width: TOOLBAR_DISC_SIZE, - height: TOOLBAR_DISC_SIZE, - marginHorizontal: bySizing({ - touch: (shapeWidth - TOOLBAR_DISC_SIZES.touch) / 2, - pointer: 0, - }), - }; +/** A labelled toolbar control's padding on each side of its content. */ +export const TOOLBAR_LABEL_PADDING = 8; + +/** An icon-only toolbar control's frame: a disc-sized circle, which is also its highlight. */ +export function toolbarSlot(): { width: LayoutValue; height: LayoutValue } { + return { width: TOOLBAR_DISC_SIZE, height: TOOLBAR_DISC_SIZE }; } -/** - * A toolbar control as wide as its content, such as a labelled picker: a disc-tall pill. Touch-sized - * its padding reaches into the gaps, so its content is what the row spaces. - */ -export function toolbarContentSlot(padding: number): { - height: LayoutValue; - paddingHorizontal: number; - marginHorizontal: LayoutValue; -} { - return { - height: TOOLBAR_DISC_SIZE, - paddingHorizontal: padding, - marginHorizontal: bySizing({ touch: -padding, pointer: 0 }), - }; +/** A toolbar control as wide as its content, such as a labelled picker: a disc-tall pill. */ +export function toolbarContentSlot(): { height: LayoutValue; paddingHorizontal: number } { + return { height: TOOLBAR_DISC_SIZE, paddingHorizontal: TOOLBAR_LABEL_PADDING }; } /** diff --git a/packages/app/src/composer/agent-controls/control.tsx b/packages/app/src/composer/agent-controls/control.tsx index c93640206a..455f26686a 100644 --- a/packages/app/src/composer/agent-controls/control.tsx +++ b/packages/app/src/composer/agent-controls/control.tsx @@ -2,7 +2,7 @@ import { forwardRef, useCallback, type ComponentProps } from "react"; import { Text, View, type PressableStateCallbackType } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; -import { TOUCH_ICON_SIZE, toolbarContentSlot, toolbarSlot } from "@/components/ui/control-geometry"; +import { toolbarContentSlot, toolbarSlot } from "@/components/ui/control-geometry"; import { useComposerControlLayout } from "@/composer/agent-controls/layout-context"; import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph"; import type { AgentControlIcon } from "@/agent-controls/icons"; @@ -105,12 +105,12 @@ const styles = StyleSheet.create((theme) => ({ flexDirection: "row", alignItems: "center", gap: theme.spacing[1], - ...toolbarContentSlot(theme.spacing[2]), + ...toolbarContentSlot(), borderRadius: theme.borderRadius.full, backgroundColor: "transparent", }, toolbarIconOnly: { - ...toolbarSlot(TOUCH_ICON_SIZE.md), + ...toolbarSlot(), flexShrink: 0, paddingHorizontal: 0, justifyContent: "center", diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index a3bb484b94..f35dfa7520 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -50,10 +50,8 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { controlSizingForLayout, TOOLBAR_CONTROL_GAP, - bySizing, toolbarGlyphColor, type ToolbarGlyphs, - TOUCH_ICON_SIZE, toolbarSlot, } from "@/components/ui/control-geometry"; import { readMeasuredWidth } from "@/hooks/use-container-width"; @@ -1452,18 +1450,15 @@ const styles = StyleSheet.create((theme) => ({ flexDirection: "row", alignItems: "center", gap: TOOLBAR_CONTROL_GAP, - // Clips pickers that run out of room. Touch-sized the pickers' pills and circles reach 8 past - // what the row spaces, so the clip reaches as far. + // Clips pickers that run out of room. overflow: "hidden", - paddingHorizontal: bySizing({ touch: theme.spacing[2], pointer: 0 }), - marginHorizontal: bySizing({ touch: -theme.spacing[2], pointer: 0 }), }, modelControl: { minWidth: 0, flexShrink: 1, }, modeIconBadge: { - ...toolbarSlot(TOUCH_ICON_SIZE.md), + ...toolbarSlot(), alignItems: "center", justifyContent: "center", paddingHorizontal: 0, diff --git a/packages/app/src/composer/agent-controls/layout.test.ts b/packages/app/src/composer/agent-controls/layout.test.ts index 15b6a4f3ac..5370c5f9a2 100644 --- a/packages/app/src/composer/agent-controls/layout.test.ts +++ b/packages/app/src/composer/agent-controls/layout.test.ts @@ -221,12 +221,13 @@ describe("composer control layout", () => { caretSize: 14, }); expect(TOUCH).toMatchObject({ - controlSize: 20, + controlSize: 36, glyphs: { icon: { size: 20, strokeWidth: 1.5, tone: "foreground" }, label: { size: 20, strokeWidth: 1.5, tone: "foreground" }, }, - controlGap: 20, + controlGap: 0, + labelPadding: 8, }); }); @@ -249,8 +250,8 @@ describe("composer control layout", () => { }); describe("touch toolbar on an iPad in portrait, sidebar docked", () => { - // Codex: model, thinking, mode and two toggle features. Touch controls lay out at what they - // paint, 20pt apart, so icon-only thinking, mode and features need 200. + // Codex: model, thinking, mode and two toggle features. Touch controls lay out at their + // 36pt highlight with no gap, so icon-only thinking, mode and features need 108. const controls = { hasModel: true, hasThinking: true, diff --git a/packages/app/src/composer/agent-controls/layout.ts b/packages/app/src/composer/agent-controls/layout.ts index 0be3361f8b..7f961b34c4 100644 --- a/packages/app/src/composer/agent-controls/layout.ts +++ b/packages/app/src/composer/agent-controls/layout.ts @@ -1,6 +1,7 @@ import { TOOLBAR_CONTROL_GAPS, - TOOLBAR_CONTROL_SIZES, + TOOLBAR_DISC_SIZES, + TOOLBAR_LABEL_PADDING, toolbarGlyphsFor, type ControlSizing, type ToolbarGlyphs, @@ -50,25 +51,19 @@ export interface ComposerToolbarGeometry { glyphs: ToolbarGlyphs; controlGap: number; iconLabelGap: number; - /** A labelled control's padding on each side, as far as it takes room in the row. */ + /** A labelled control's padding on each side. */ labelPadding: number; caretSize: number; } -/** - * The toolbar's measurements under a control sizing; see `resolveControlSizing`. Touch-sized a - * control lays out at the width of what it paints (`toolbarSlot`, `toolbarContentSlot`), so an - * icon-only control takes its glyph's width and a picker's padding takes no room. - */ +/** The toolbar's measurements under a control sizing; see `resolveControlSizing`. */ export function composerToolbarGeometry(sizing: ControlSizing): ComposerToolbarGeometry { - const glyphs = toolbarGlyphsFor(sizing); - const isTouch = sizing === "touch"; return { - controlSize: isTouch ? glyphs.icon.size : TOOLBAR_CONTROL_SIZES.pointer, - glyphs, + controlSize: TOOLBAR_DISC_SIZES[sizing], + glyphs: toolbarGlyphsFor(sizing), controlGap: TOOLBAR_CONTROL_GAPS[sizing], iconLabelGap: 4, - labelPadding: isTouch ? 0 : 8, + labelPadding: TOOLBAR_LABEL_PADDING, caretSize: 14, }; } diff --git a/packages/app/src/composer/agent-controls/model-sheet.tsx b/packages/app/src/composer/agent-controls/model-sheet.tsx index 6a34dbae4f..c9da7d778e 100644 --- a/packages/app/src/composer/agent-controls/model-sheet.tsx +++ b/packages/app/src/composer/agent-controls/model-sheet.tsx @@ -27,7 +27,6 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { isNative, isWeb } from "@/constants/platform"; import { readMeasuredWidth } from "@/hooks/use-container-width"; import { - TOUCH_ICON_SIZE, toolbarContentSlot, toolbarSlot, type ToolbarGlyphs, @@ -535,12 +534,12 @@ const styles = StyleSheet.create((theme) => ({ flexDirection: "row", alignItems: "center", gap: theme.spacing[1], - ...toolbarContentSlot(theme.spacing[2]), + ...toolbarContentSlot(), borderRadius: theme.borderRadius.full, backgroundColor: "transparent", }, triggerIconOnly: { - ...toolbarSlot(TOUCH_ICON_SIZE.md), + ...toolbarSlot(), flexShrink: 0, paddingHorizontal: 0, justifyContent: "center", diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index ec0c688176..d824c0dd63 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -42,7 +42,6 @@ import { type ToolbarGlyph, toolbarSlot, } from "@/components/ui/control-geometry"; -import { COMPOSER_TOOLBAR_SHAPES } from "@/composer/input/toolbar-rails"; import { usePanelStore } from "@/stores/panel-store"; import { usePointerCapabilities } from "@/hooks/pointer-capabilities"; import { useShallow } from "zustand/shallow"; @@ -2640,7 +2639,7 @@ const styles = StyleSheet.create((theme: Theme) => ({ gap: theme.spacing[3], }, cancelButton: { - ...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc), + ...toolbarSlot(), alignItems: "center", justifyContent: "center", }, @@ -2658,7 +2657,7 @@ const styles = StyleSheet.create((theme: Theme) => ({ gap: TOOLBAR_CONTROL_GAP, }, contextWindowMeterSlot: { - ...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.ring), + ...toolbarSlot(), flexShrink: 0, alignItems: "center", justifyContent: "center", @@ -2672,7 +2671,7 @@ const styles = StyleSheet.create((theme: Theme) => ({ justifyContent: "center", }, realtimeVoiceButton: { - ...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc), + ...toolbarSlot(), borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 48c4132b9d..3464efd16e 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -40,12 +40,12 @@ import { type ToolbarGlyph, toolbarSlot, } from "@/components/ui/control-geometry"; +import { bySizing } from "@/components/ui/control-sizing"; import { COMPOSER_PADDING_BOTTOM, COMPOSER_PADDING_HORIZONTAL, COMPOSER_CORNER_RADIUS, COMPOSER_PADDING_TOP, - COMPOSER_TOOLBAR_SHAPES, composerToolbarOverhang, resolveComposerLeadingEdge, } from "@/composer/input/toolbar-rails"; @@ -1983,7 +1983,6 @@ export const MessageInput = forwardRef( const voiceButtonStyle = useCallback( ({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [ styles.voiceButton, - isDictating && styles.voiceButtonRecording, Boolean(hovered || pressed) && !isDictating && styles.iconButtonHovered, Boolean(pressed) && isDictating && styles.discPressed, !isDictationStartEnabled && styles.buttonDisabled, @@ -2238,7 +2237,7 @@ export const MessageInput = forwardRef( voiceMuteToggleKeys={voiceMuteToggleKeys} dictationToggleKeys={dictationToggleKeys} /> - {sendButton} + {sendButton} ), @@ -2470,8 +2469,16 @@ const styles = StyleSheet.create((theme: Theme) => ({ alignItems: "center", gap: TOOLBAR_CONTROL_GAP, }, + // Touch-sized the row's highlights touch, but the disc is always painted, so a pressed neighbour + // keeps clear of it instead of merging with it. + trailingDisc: { + marginLeft: bySizing({ touch: theme.spacing[1.5], pointer: 0 }), + }, + // Touch-sized the plus is all thin arms, so beside the picker's glyph the same gap reads wider. + // Its highlight can overlap the picker's: only one control is ever pressed. attachButton: { - ...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.attach), + ...toolbarSlot(), + marginRight: bySizing({ touch: -2, pointer: 0 }), borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", @@ -2483,14 +2490,11 @@ const styles = StyleSheet.create((theme: Theme) => ({ justifyContent: "center", }, voiceButton: { - ...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.dictation), + ...toolbarSlot(), borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", }, - voiceButtonRecording: { - marginHorizontal: toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc).marginHorizontal, - }, voiceButtonRecordingDisc: { width: TOOLBAR_DISC_SIZE, height: TOOLBAR_DISC_SIZE, @@ -2501,7 +2505,6 @@ const styles = StyleSheet.create((theme: Theme) => ({ }, sendButton: { minWidth: TOOLBAR_DISC_SIZE, - marginHorizontal: toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc).marginHorizontal, height: TOOLBAR_DISC_SIZE, alignItems: "center", justifyContent: "center", diff --git a/packages/app/src/composer/input/toolbar-rails.test.ts b/packages/app/src/composer/input/toolbar-rails.test.ts index 1b4caadd2d..002de77071 100644 --- a/packages/app/src/composer/input/toolbar-rails.test.ts +++ b/packages/app/src/composer/input/toolbar-rails.test.ts @@ -2,16 +2,17 @@ import { describe, expect, it } from "vitest"; import { composerToolbarOverhang, resolveComposerLeadingEdge } from "./toolbar-rails"; describe("composer toolbar rails", () => { - it("keeps touch glyphs and labels on the text rails and nests a trailing disc into the corner", () => { - for (const edge of ["attach", "dictation", "voiceMode", "label", "none"] as const) { - expect(composerToolbarOverhang(edge, "touch")).toBe(0); - } + it("hangs each end highlight far enough to put its ink on the text rail and nests a trailing disc into the corner", () => { + // The 36pt highlight around the plus's ink (14/24 of a 20pt glyph, plus its 1.5 stroke). + expect(composerToolbarOverhang("attach", "touch")).toBeCloseTo((36 - (14 * 20) / 24 - 1.5) / 2); + expect(composerToolbarOverhang("label", "touch")).toBe(8); + expect(composerToolbarOverhang("none", "touch")).toBe(0); // A 36pt disc centred in the 44pt row sits 8 from the bottom edge, so it sits 8 from the side. expect(composerToolbarOverhang("disc", "touch")).toBe(8); }); it("keeps the pointer row's flat overhang", () => { - for (const edge of ["attach", "dictation", "voiceMode", "disc", "label", "none"] as const) { + for (const edge of ["attach", "disc", "label", "none"] as const) { expect(composerToolbarOverhang(edge, "pointer")).toBe(6); } }); diff --git a/packages/app/src/composer/input/toolbar-rails.ts b/packages/app/src/composer/input/toolbar-rails.ts index 1cbca21224..9ae9765a48 100644 --- a/packages/app/src/composer/input/toolbar-rails.ts +++ b/packages/app/src/composer/input/toolbar-rails.ts @@ -1,14 +1,14 @@ import { TOOLBAR_CONTROL_SIZES, TOOLBAR_DISC_SIZES, - TOOLBAR_RINGS, + TOOLBAR_LABEL_PADDING, toolbarGlyphsFor, type ControlSizing, } from "@/components/ui/control-geometry"; import { bySizing } from "@/components/ui/control-sizing"; /** The control at one end of the composer toolbar, which decides how far the row hangs out. */ -export type ComposerToolbarEdge = "attach" | "label" | "dictation" | "voiceMode" | "disc" | "none"; +export type ComposerToolbarEdge = "attach" | "label" | "disc" | "none"; /** * The composer's padding. Its sides are the rails: the text column and the toolbar's outer @@ -32,23 +32,12 @@ export const COMPOSER_PADDING_BOTTOM = bySizing({ pointer: COMPOSER_PADDING.pointer.bottom, }); -/** How much of Lucide's 24-unit grid each toolbar glyph's ink spans, centred in its box. */ -const GLYPH_INK_SPANS = { plus: 14, mic: 14, audioLines: 20 } as const; - -function touchGlyphInkWidth(span: number): number { +/** The plus glyph's ink: 14 of Lucide's 24 units, plus its stroke. */ +function attachGlyphInkWidth(): number { const glyph = toolbarGlyphsFor("touch").icon; - return (span * glyph.size) / 24 + glyph.strokeWidth; + return (14 * glyph.size) / 24 + glyph.strokeWidth; } -/** The width each toolbar control paints when touch-sized; the row spaces these. */ -export const COMPOSER_TOOLBAR_SHAPES = { - attach: touchGlyphInkWidth(GLYPH_INK_SPANS.plus), - dictation: touchGlyphInkWidth(GLYPH_INK_SPANS.mic), - voiceMode: touchGlyphInkWidth(GLYPH_INK_SPANS.audioLines), - ring: TOOLBAR_RINGS.touch.size, - disc: TOOLBAR_DISC_SIZES.touch, -} as const; - /** Pointer-sized the row hangs a flat 6px into the padding, which puts each 16pt glyph box on the rail. */ const POINTER_OVERHANG = 6; @@ -68,10 +57,20 @@ export const COMPOSER_CORNER_RADIUS = bySizing({ pointer: 16, }); +/** + * Touch-sized each end control's highlight hangs past the rail far enough to put what it paints on + * the rail. The trailing disc paints its whole highlight, so it nests into the corner instead. + */ +const TOUCH_OVERHANGS: Record = { + attach: (TOOLBAR_DISC_SIZES.touch - attachGlyphInkWidth()) / 2, + label: TOOLBAR_LABEL_PADDING, + disc: DISC_CORNER_NEST, + none: 0, +}; + /** How far the composer toolbar hangs past the text column at one end. */ export function composerToolbarOverhang(edge: ComposerToolbarEdge, sizing: ControlSizing): number { - if (sizing === "pointer") return POINTER_OVERHANG; - return edge === "disc" ? DISC_CORNER_NEST : 0; + return sizing === "pointer" ? POINTER_OVERHANG : TOUCH_OVERHANGS[edge]; } /** The control that starts the toolbar on the left. */