fix(compose): a toolbar that wraps, a pill on the phone, and a way back out of plain text

Five reports from one window, and four of them turn out to be the same
mistake: the two toolbar rows were sideways scrollers.

  • The emoji panel opened off screen on a desktop. It was anchored to its
    button, which sits near the right edge of a 520px dock. It is centred on
    the compose WINDOW now, at every width and in both compose modes — the
    same treatment a phone already got by being pinned to the viewport, and
    one rule instead of two.

  • Leaving plain-text mode did not bring the formatting bar back. Going
    plain wrote `display: none`; coming back wrote nothing, so a rich-mode
    window sat there with no bar and no reason why. The switch is symmetric
    now, and restores the bar to what it WAS rather than simply opening it —
    a bar folded on purpose stays folded across the round trip. Same for the
    link, emoji, image and signature buttons, which are greyed while plain
    (they write into an editor that is hidden) and restored on the way out.

  • Neither toolbar row scrolls horizontally any more; both wrap onto a
    second row. `overflow-x: auto` computes `overflow-y: auto` with it, which
    is what clipped the more-options menu to nothing on a phone last round —
    so removing the scrollers also let the `max-md:fixed` viewport-pinning
    workarounds be deleted rather than kept. Every popover now anchors to the
    toolbar block, which spans the window. Two rows at 393px and at 320px:
    the group dividers go `hidden md:block` below md, which is the 25px
    between two rows and a third holding nothing but Redo.

  • The send pill is rendered at every width, so scheduling exists on a phone
    for the first time; the header's separate Send icon is gone rather than
    left to make a second, less capable Send button. Both halves of the pill
    take a 44px minimum below md — the chevron was an 11px hit area.

  • The formatting bar is shown by default on a phone as well as a desktop.
    "Aa" still folds it away for whoever wants the room.

Measured at 320x568 with all of it up: 62px of format bar, 84px of action
bar, 349px left for the body. At 393x851, 632px.

Tests: compose-mobile gains the no-sideways-scroll assertion, a
both-ends-on-screen assertion and a body-height floor; compose-scheduled-send
inverts its "the pill is desktop-only" block into measured pill-and-menu
geometry at 393px and 320px plus a preset set from the phone itself;
compose-signature-and-options gains the round trip that would have caught
this — a one-way test is what let it through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5 committed 2026-08-13 09:07:33 +02:00
1 parent 650b98939d
commit 9c37bc3a8f
6 files changed
+615 -242

No files matched your search

+149 -103
View File
@@ -25,8 +25,8 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
{# Window-chrome button. Larger below md so it clears the 44px touch target. #}
{% set hc = "w-9 h-9 md:w-7 md:h-7 flex items-center justify-center rounded-lg
text-ink-muted hover:text-ink hover:bg-hover transition-colors shrink-0" %}
{# Formatting-toolbar button: shrink-0 so it keeps its width inside the
horizontal scroller the toolbar becomes below md. #}
{# Formatting-toolbar button: shrink-0 so it keeps its own width when the
toolbar wraps onto a second row rather than being squeezed thin. #}
{% set tb = "shrink-0 px-1.5 py-0.5 rounded text-sm transition-colors text-ink-muted hover:bg-hover" %}
<turbo-frame id="{{ frame }}">
@@ -149,15 +149,14 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
<span data-compose--compose-target="saveStatus"
class="text-xs text-ink-muted mr-2 transition-colors truncate max-w-[40vw] md:max-w-none"></span>
{# Send is the one action that must never be scrolled away, so
below md it lives up here rather than in the bottom bar,
which is a horizontal scroller. #}
<button type="submit" title="{{ 'compose.send'|trans }}"
aria-label="{{ 'compose.send'|trans }}"
data-compose--compose-target="sendBtn"
class="md:hidden {{ hc }} text-accent hover:text-accent">
<i class="fa-solid fa-paper-plane text-sm" aria-hidden="true"></i>
</button>
{# There was a second Send here below md — an icon button in the
header — because the bottom bar used to be a horizontal
scroller Send could be scrolled out of. The bar wraps now and
nothing in it can be scrolled away, so the send PILL is
rendered at every width instead (see the action bar), and it
is the pill that carries the schedule chevron. Two Send
buttons on one phone screen, only one of which could schedule,
would be worse than the bug this replaced. #}
{% if inline %}
{# Popping out lands in the dock, which below md is the
@@ -426,14 +425,20 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
</div>
{# ── Toolbar ──────────────────────────────────────────── #}
{# `relative` so the two popovers below can anchor to THIS block
rather than to a button. Both the format bar and the icon row
scroll sideways on a phone (.compose-scroll-x), and anything
anchored to a control inside one of them opens wherever that
control happens to have been scrolled to — which is how the emoji
panel came to open past the right edge of the screen. This
wrapper spans the window and never scrolls, so a popover pinned
to it is on screen by construction at every width. #}
{# `relative` so EVERY popover below anchors to THIS block rather
than to the button that opens it — the link popover, the
plain-text warning, the emoji panel, the more-options menu and
the integration picker alike.
The buttons themselves sit in rows that wrap, so their x position
depends on how many icons fitted on the line above; a menu
anchored to one of them lands wherever that button happened to
land, which at 320px is routinely past the right edge. This
wrapper spans the window, so a popover pinned to it is on screen
by construction at every width and in both compose modes. It is
also why none of them need the `max-md:fixed` viewport pinning
they used to carry: that was a workaround for the sideways
scrollers, and the scrollers are gone. #}
<div class="relative px-2 md:px-3 pb-2 md:pb-3 pt-1 shrink-0">
{# ── Link popover ─────────────────────────────────────
@@ -441,9 +446,9 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
same toolbar controller and had the same `prompt()`. The
positioning is passed in because it is the one thing the two
hosts disagree about: here it is anchored to the toolbar
BLOCK, which spans the window and never scrolls — the link
button itself lives in a sideways scroller and would drag
the popover off the screen with it. #}
BLOCK, which spans the window — the link button itself sits
in a row that wraps, and a popover anchored to it would hang
off whichever edge the button wrapped to. #}
{{ include('compose/_link_popover.html.twig', {
id: form.vars.id,
class: 'absolute bottom-full mb-2 left-2 right-2 md:left-auto md:right-3 md:w-80',
@@ -488,14 +493,30 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
</div>
</div>
{# One line that scrolls sideways below md — wrapping it would
eat three rows of a phone screen. The controller hides it
there until "Aa" asks for it. #}
{# Wraps onto a second row rather than scrolling sideways.
Scrolling hid controls behind a gesture nobody performs, and
its `overflow-x: auto` computed `overflow-y: auto` with it,
which clipped every menu that opened upwards out of this
strip. Two rows cost less than either.
`rounded-2xl` rather than `rounded-full`: a pill radius on a
two-row block bows its sides in. It is still a pill when the
bar fits on one 32px line, which is the desktop case.
Shown by default at every width now — the controller only
folds it away when "Aa" asks, or in plain-text mode where it
has nothing to act on.
The group dividers are `hidden md:block`. They are 1px of
decoration whose whole job is to say "these belong together",
and a wrap already breaks that grouping in a way a hairline
cannot mend — while the 25px they cost was the difference
between two rows and a third row holding nothing but Redo at
320px. Measured: 62px with them hidden, 90px with them. #}
<div data-compose--compose-target="formatBar"
class="flex items-center gap-0.5 mb-2 flex-nowrap md:flex-wrap
overflow-x-auto md:overflow-visible compose-scroll-x
class="flex flex-wrap items-center gap-x-0.5 gap-y-1 mb-2
bg-sunken
rounded-full px-2 py-1">
rounded-2xl px-2 py-1">
{# Bare rather than framed: this is a strip of icon buttons,
and two boxed fields in the middle of it would read as a
@@ -519,7 +540,7 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
<option value="monospace">{{ 'compose.font_family.monospace'|trans }}</option>
</select>
<div class="w-px h-4 bg-line mx-1 shrink-0"></div>
<div class="hidden md:block w-px h-4 bg-line mx-1 shrink-0"></div>
<select data-compose--compose-toolbar-target="fontSize"
data-action="change->compose--compose-toolbar#changeFontSize"
@@ -534,7 +555,7 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
<option value="huge">{{ 'compose.font_size.huge'|trans }}</option>
</select>
<div class="w-px h-4 bg-line mx-1 shrink-0"></div>
<div class="hidden md:block w-px h-4 bg-line mx-1 shrink-0"></div>
<button type="button" data-compose--compose-toolbar-action data-compose--compose-toolbar-target="boldBtn"
data-action="click->compose--compose-toolbar#bold"
@@ -570,7 +591,7 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer">
</label>
<div class="w-px h-4 bg-line mx-1 shrink-0"></div>
<div class="hidden md:block w-px h-4 bg-line mx-1 shrink-0"></div>
{% set toolbarButtons = [
{ action: 'alignLeft', icon: 'fa-align-left', label: 'compose.align.left' },
@@ -588,7 +609,7 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
{% for item in toolbarButtons %}
{% if item.divider|default(false) %}
<div class="w-px h-4 bg-line mx-1 shrink-0"></div>
<div class="hidden md:block w-px h-4 bg-line mx-1 shrink-0"></div>
{% else %}
<button type="button" data-compose--compose-toolbar-action
data-action="click->compose--compose-toolbar#{{ item.action }}"
@@ -600,14 +621,29 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
{% endfor %}
</div>
{# Action bar #}
<div class="flex items-center gap-1">
{# ── Action bar ───────────────────────────────────────
ONE wrapping row, not a pill beside a nested strip of icons.
The icons used to live in their own `flex-1 min-w-0` box, and
at 320px that box was ~150px wide with the send pill taking
the rest — so the icons wrapped three deep inside a narrow
column while the trash can floated at the right of it. Flat,
they wrap as one run: pill and four icons on the first line,
the remaining five on the second, and two rows at 320px as
well as at 393px. #}
<div class="flex flex-wrap items-center gap-1">
{# ── The Send pill, and behind its chevron, send later ──
Desktop only — below md the header carries Send, which
is always on screen. Which is also why the send-options
menu needs no mobile treatment: there is no chevron on a
phone for it to open off the edge of.
Rendered at EVERY width. It used to be `hidden md:flex`,
with a plain Send icon in the header standing in for it
below md — which meant scheduling simply did not exist on
a phone, and the Drafts row was the only place a hold
could be seen at all. The header icon is gone; this is the
one Send button now, so there is no pair of them to
disagree about which can schedule.
Both halves take a 44px minimum below md. The chevron in
particular was an 11px hit area at its natural size, which
is a control you aim at rather than press.
The positioning box is OUTSIDE the pill because the pill
is `overflow-hidden` (that is what rounds the two
@@ -632,7 +668,7 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
{% set scheduledAt = (message.id and message.sentAt is null)
? message.submissionSendAt : null %}
<div class="relative hidden md:block mr-2 shrink-0"
<div class="relative mr-2 shrink-0"
data-controller="ui--dropdown compose--schedule"
data-action="ui--dropdown:opened->compose--schedule#refresh"
{# The zone every other timestamp in this app is drawn
@@ -653,32 +689,33 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
<div class="flex items-center rounded-full overflow-hidden bg-accent">
<button type="submit"
data-compose--compose-target="sendBtn"
class="text-white text-sm font-semibold px-4 py-1.5 hover:bg-white/10 transition-colors">
class="flex items-center justify-center text-white text-sm font-semibold
px-4 py-1.5 max-md:min-h-11 hover:bg-white/10 transition-colors">
{{ 'compose.send'|trans }}
</button>
<button type="button"
data-action="click->ui--dropdown#toggle"
title="{{ 'compose.send_options'|trans }}"
aria-label="{{ 'compose.send_options'|trans }}"
class="text-white border-l border-white/30 px-2.5 py-1.5 hover:bg-white/10 transition-colors text-xs">
class="flex items-center justify-center text-white border-l border-white/30
px-2.5 py-1.5 max-md:min-h-11 max-md:min-w-11
hover:bg-white/10 transition-colors text-xs">
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
</button>
</div>
{# `right-0` on desktop: the pill sits at the left of a
520px window, and a menu anchored to its right edge
opens back across the window rather than off it.
Flipped to `left-0` below md, exactly as the
integration picker flips its own. The pill is hidden
at that width today, so this costs nothing now — but
right-anchored at 393px the menu's left edge lands at
-140, and the day the pill is ever shown on a phone
the failure would be a menu half off the screen with
no obvious cause. compose-scheduled-send.spec.ts
measures it either way. #}
{# `left-0` at every width, not `right-0`. The pill is
the FIRST thing in the action bar, so its right edge
is barely 100px from the left of the window: a menu
anchored there hung 150px off the window's left side
on desktop and, at 393px, off the screen. Anchored to
the pill's left edge it opens inward from the same
x the button starts at, in a 520px dock window and in
a 320px phone alike — `max-w` is what keeps the
narrow end honest. compose-scheduled-send.spec.ts
measures the box at 393px and at 320px. #}
<div data-ui--dropdown-target="menu" hidden
class="absolute bottom-full mb-1 right-0 max-md:left-0 max-md:right-auto
class="absolute bottom-full mb-1 left-0 right-auto
z-20 w-64 max-w-[calc(100vw-2rem)] popover py-1">
{% if scheduledAt is not null %}
@@ -779,9 +816,12 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
</div>
{% set ic = "shrink-0 w-9 h-9 md:w-8 md:h-8 flex items-center justify-center rounded-full transition-colors text-ink-muted hover:text-ink hover:bg-hover" %}
{# Scrolls sideways rather than wrapping or clipping, so
every icon stays reachable on a phone. #}
<div class="flex-1 min-w-0 flex items-center gap-1 overflow-x-auto md:overflow-visible compose-scroll-x">
{# The icons are direct children of the wrapping action bar
above — no box of their own. The box was a sideways
scroller (`overflow-x: auto`, which computes
`overflow-y: auto` and clipped every menu opening upwards
out of the 36px strip), and once it stopped scrolling all
it did was corner the icons into a narrow column. #}
<button type="button"
data-action="click->compose--compose#toggleFormatBar"
data-compose--compose-target="formatToggle"
@@ -808,6 +848,7 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
field prefills empty. #}
<button type="button" data-compose--compose-toolbar-action
data-compose--compose-toolbar-target="linkBtn"
data-compose--compose-target="richOnly"
data-action="click->compose--compose-toolbar#insertLink"
aria-haspopup="dialog"
aria-expanded="false"
@@ -816,14 +857,19 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
class="{{ ic }}"><i class="fa-solid fa-link" aria-hidden="true"></i></button>
{# ── Emoji ─────────────────────────────────────────
Same dropdown shape as the integration picker below,
so Escape and outside-click come free. The panel opens
upwards and is pinned to the left edge of the window on
a phone: the button itself lives in a strip that scrolls
sideways, and a menu anchored to it would open off the
screen. #}
<div class="relative"
data-controller="ui--dropdown compose--emoji-picker"
Same dropdown shape as the integration picker below, so
Escape and outside-click come free.
Deliberately NOT `relative`: without a positioned
ancestor of its own the panel's containing block is the
toolbar block, which spans the window — so the panel
centres over the compose WINDOW rather than over a button
sitting near its right edge. Which is what it already did
below md by being `fixed` to the viewport, and what it
failed to do above md, where a 352px panel hung off the
side of a 520px dock. One rule now covers the dock, an
inline reply, expanded, and a phone. #}
<div data-controller="ui--dropdown compose--emoji-picker"
data-action="ui--dropdown:opened->compose--emoji-picker#build"
data-compose--emoji-picker-locale-value="{{ app.request.locale|split('_')|first }}"
data-compose--emoji-picker-data-source-value="{{ asset('emoji/' ~ (app.request.locale starts with 'de' ? 'de' : 'en') ~ '/data.json') }}"
@@ -862,15 +908,21 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
},
}|json_encode|e('html_attr') }}">
<button type="button" data-compose--compose-toolbar-action
data-compose--compose-target="richOnly"
data-action="click->ui--dropdown#toggle"
title="{{ 'compose.insert_emoji'|trans }}"
aria-label="{{ 'compose.insert_emoji'|trans }}"
class="{{ ic }}"><i class="fa-regular fa-face-smile" aria-hidden="true"></i></button>
{# Centred on the window: half its width across, pulled
back by half its own. `calc(100%-1rem)` is the
window minus the 8px gutter the phone version used to
set with left-2/right-2; `.emoji-panel`'s max-width
caps it at 22rem above md and releases it below. #}
<div data-ui--dropdown-target="menu" hidden
data-compose--emoji-picker-target="panel"
class="emoji-panel fixed left-2 right-2 bottom-20
md:absolute md:inset-auto md:bottom-full md:left-0 md:right-auto
md:mb-1 z-30 popover overflow-hidden"></div>
class="emoji-panel absolute bottom-full mb-2
left-1/2 -translate-x-1/2 w-[calc(100%-1rem)]
z-30 popover overflow-hidden"></div>
</div>
{# ── Inline image ──────────────────────────────────
@@ -880,6 +932,7 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
image/*. Paste and drag-drop reach the same endpoint —
see _handleBodyPaste in the controller. #}
<button type="button" data-compose--compose-toolbar-action
data-compose--compose-target="richOnly"
data-action="click->compose--compose#openImagePicker"
title="{{ 'compose.image'|trans }}"
aria-label="{{ 'compose.image'|trans }}"
@@ -925,18 +978,19 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
which is the same operation the From switch performs, and
deliberately the same code. #}
<button type="button" data-compose--compose-toolbar-action
data-compose--compose-target="richOnly"
data-action="click->compose--compose#insertSignature"
title="{{ 'compose.signature'|trans }}"
aria-label="{{ 'compose.signature'|trans }}"
class="{{ ic }}"><i class="fa-solid fa-pen-nib" aria-hidden="true"></i></button>
{# ── More options ──────────────────────────────────
Same dropdown shape as the integration picker below and
the schedule menu above: a relative wrapper, a `menu`
target that starts hidden, `bottom-full` so it opens
Same dropdown shape as the schedule menu above: a `menu`
target that starts hidden and `bottom-full` so it opens
upwards out of a window already at the bottom of the
screen, and `left-0` below md because this button sits in
a strip that scrolls sideways.
screen. Like the emoji panel it has no `relative` wrapper
of its own, so it anchors to the toolbar block instead of
to a button whose x depends on where the row wrapped.
The two form fields inside it are REAL widgets, not
decoration. ComposeType maps both onto the Message and
@@ -945,36 +999,30 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
and readReceiptRequested to false — that is, the menu
being rendered is what makes the fields exist at all.
They ride the existing draft/send POSTs; no new route. #}
<div class="relative" data-controller="ui--dropdown">
<div data-controller="ui--dropdown">
<button type="button"
data-action="click->ui--dropdown#toggle"
title="{{ 'compose.more_options'|trans }}"
aria-label="{{ 'compose.more_options'|trans }}"
class="{{ ic }}"><i class="fa-solid fa-ellipsis-vertical" aria-hidden="true"></i></button>
{# Below md this menu is pinned to the VIEWPORT rather
than to its button, exactly as the emoji panel is and
for exactly the same reason — except that here it was
not merely mispositioned, it was unreachable. The
button sits in `.compose-scroll-x`, whose
`overflow-x: auto` computes `overflow-y: auto` as
well, so a menu opening UPWARDS out of a 36px strip
was clipped to nothing: on a phone the three controls
in here (plain text, priority, read receipt) could be
tapped open and never seen. A fixed element is not
clipped by an overflow ancestor — its containing
block is .compose-window, which below md is the
screen — which is the whole trick.
{# This menu used to be pinned to the VIEWPORT below md
(`max-md:fixed`, `bottom-20`) because the button sat
in `.compose-scroll-x`, whose `overflow-x: auto`
computed `overflow-y: auto` as well — a menu opening
UPWARDS out of a 36px strip was clipped to nothing,
so the three controls in here (plain text, priority,
read receipt) could be tapped open and never seen.
`bottom-20` clears the action bar; `w-auto` releases
the `w-64` so left-2/right-2 can set the width.
compose-mobile.spec.ts measures the box against the
viewport at 393px and at 320px. #}
The strip does not scroll and does not clip any more,
so the pinning is deleted rather than kept: the same
left-2/right-2 the link popover uses, anchored to the
toolbar block, is on screen at every width without
leaving the flow. compose-mobile.spec.ts measures the
box against the viewport at 393px and at 320px. #}
<div data-ui--dropdown-target="menu" hidden
class="absolute bottom-full mb-1 left-0 md:right-auto z-30
w-64 max-w-[calc(100vw-2rem)] popover py-1
max-md:fixed max-md:inset-auto max-md:left-2 max-md:right-2
max-md:bottom-20 max-md:w-auto max-md:max-w-none">
class="absolute bottom-full mb-2 left-2 right-2
md:left-auto md:right-3 md:w-64 z-30 popover py-1">
{# Plain text. A toggle, not a submit: what it
changes is which surface the body lives in, and
@@ -1027,7 +1075,6 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
</label>
</div>
</div>
</div>
{# Attach from a connected service. Sized to what the user
actually has: nothing at all with no connections, the
@@ -1044,19 +1091,18 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
title="{{ 'integration.picker.attach_from'|trans({ '%name%': only.name }) }}"
class="{{ ic }}"><i class="{{ only.provider.icon }}"></i></button>
{% elseif pickerIntegrations|length > 1 %}
<div class="relative" data-controller="ui--dropdown">
<div data-controller="ui--dropdown">
<button type="button"
data-action="click->ui--dropdown#toggle"
title="{{ 'integration.picker.attach_from_service'|trans }}"
class="{{ ic }}"><i class="fa-solid fa-cloud-arrow-down"></i></button>
{# Pinned to the viewport below md, same as the more-
{# Anchored to the toolbar block, same as the more-
options menu above and for the same reason: this
button is in the sideways scroller too, and a
menu opening upwards out of it is clipped away. #}
button is in the wrapping row too, and a menu
anchored to it lands wherever the row wrapped. #}
<div data-ui--dropdown-target="menu" hidden
class="absolute bottom-full mb-1 right-0 md:left-0 md:right-auto z-30 min-w-48 popover py-1
max-md:fixed max-md:inset-auto max-md:left-2 max-md:right-2
max-md:bottom-20 max-md:min-w-0">
class="absolute bottom-full mb-2 left-2 right-2
md:left-auto md:right-3 md:w-64 z-30 popover py-1">
{% for integration in pickerIntegrations %}
<button type="button"
data-controller="ui--modal"