Space the touch composer toolbar by what it paints (#8909)

* feat(app): tighten the touch composer toolbar onto the text column

On touch, the toolbar's icon-only glyphs were 20pt muted outlines at a 1.5px
stroke in loosely spaced slots, and the row sat on a flat -6 margin, so the
attach + and the trailing control floated inside the text column. Touch now
places 44pt slots edge to edge, draws icon-only glyphs at 24pt with a 2px
foreground stroke, keeps label-paired glyphs at 20pt, and runs the context
ring at 26pt with a 3px stroke. The row overhangs into the composer padding
by each end control's ink inset (composerToolbarOverhang), so the + ink and
the trailing glyph or send/stop disc sit on the text column's edges.
Pointer sizing is unchanged.

The folded model control now drops its thinking label before the model
label truncates, and the model label truncates on a word boundary
("Ten second…") instead of leaving a space before the ellipsis. Both are
decided from measured widths. The web reports layout widths rounded to whole
pixels, so a tie counts as not fitting.

* fix(app): measure replacement model labels instead of hiding them

The folded model label and TailEllipsisText kept their measuring views mounted
when the label text changed, while discarding the cached widths. A retained
measuring node whose size does not change emits no new layout event on web or
native, so after switching to a model whose label (or a shortened prefix of it)
has the same width, the widths stayed unknown and the label stayed transparent.
Keying each measurer by what it measures remounts it, so every width is
measured again.

Also move the label width sweep into a named helper so the test body states
the expected label order.

* feat(app): space the touch composer toolbar by what it paints

- Lay each control out at its painted width, 20pt apart, with a disc-sized circle that is both its frame and its fully rounded pressed highlight. Pickers are 36pt pills.
- Match the header icons: 20pt glyphs at 1.5px, an 18pt context ring at 2px.
- Always end the row in a disc (voice mode, send or stop) so it does not shift, nested into the composer's corner, with a concentric 26pt corner radius on touch.
- Stop the picker row's clip from flattening the picker highlight's left edge.
- Budget the density ladder on laid-out widths.

* fix(app): disable the stand-in send disc and keep form model fields in their box

- An empty new chat's stand-in Send is disabled until there is content, as in fullscreen.
- A custom model selector trigger, such as the schedule form's field, drops the toolbar slot's margins.
This commit is contained in:
Mohamed Boudra
2026-11-06 15:56:09 +07:00
committed by GitHub
parent 4281372bee
commit 4b285a113d
26 changed files with 1674 additions and 344 deletions
+6 -2
View File
@@ -68,7 +68,11 @@ 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. 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()`.
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.
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`).
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.
@@ -140,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 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`).
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.