feat(ui): the interface stops appearing out of nowhere

Every animated surface, against the contract in the previous commit. Four
agents worked in parallel on the mail list, compose, the shared chrome and the
calendar; what follows is what survived review.

**The rule everything obeys: motion marks an ARRIVAL, never a redraw.** The
mail list is the case that decides the design. It is re-rendered wholesale —
`live.innerHTML = incoming.innerHTML` in mail--mail-pane#_swapRegions — on every
background sync, as often as every fifteen seconds, so every row becomes a new
node carrying the same mail. A row that animated on insertion would pulse four
times a minute on an inbox nobody is touching, and a list that shimmers at rest
is the fastest possible way to make somebody switch this off.

What tells the two apart is the id. `thread_1234` is the same conversation
whoever rendered it, so motion.js animates an element the first time it sees a
given id and never again. New mail rises into place; a redraw of the same mail
is silent; and a whole new list — a folder, a search, the next page — plays ONE
fade on the <ul> rather than fifty on the rows, because a child whose ancestor
is announcing it stays quiet.

That needed a second attribute, `data-enter-new`, and the reason is worth
stating because it was learned the hard way: a CSS animation starts the instant
an element carrying one is inserted, before any observer can object. Suppression
therefore cannot be a decision taken after insertion — an element whose entrance
is conditional has to arrive with no animation attached at all and be given one
only once its id proves new. The first implementation recorded ids inside
play() after an early return, so rows suppressed by their list's fade were never
recorded and animated in a slab on the next sync. A test caught it.

Elsewhere the agents mostly declined, which is the right ratio:

  • Search suggestions animate ONCE when the panel opens, not per keystroke —
    five characters produce one animation.
  • The calendar animates the grid WRAPPER on a view change: one gesture at one
    duration however many events are on screen, and nothing on the positioned
    blocks, where an animation would win over the inline transform the drag
    controller writes and corrupt its geometry reads. Proven: every block
    reports animation-name: none at all three tiers.
  • Compose declines minimise, expand, close, discard and pop-out. The first
    two are size changes; the rest are frequent actions where an exit is time
    between the ask and the answer.
  • The compose window uses `fade` where the contract said `pop`. Below md it
    IS the screen, so scaling draws a hairline of the mail list around all four
    edges, and a transform on it is inherited by everything measured inside it
    — the emoji picker's fit-to-window pass reads exactly that. The contract
    was wrong; motion.css now says so.

Two exits, both earned: a toast dismissing itself, and a modal, which is most
of the screen and whose backdrop is all of it.

Fixed along the way: closing a modal stopped returning focus to its trigger,
because the hide is now deferred through leave() and Turbo's
focusFirstAutofocusableElement was landing after the focus call — harmless when
the dialog was display:none by then, not harmless when it is still on screen for
a fade. Focus returns before the animation now, and the dialog goes `inert` on
its way out. And the motion setting's radiogroup labelled itself with an
sr-only span, which is 1px-clipped by definition and which preview-pane.spec.ts
correctly read as 53px of overflow; the heading is the label.

Verified with a getAnimations() harness rather than by eye: every entrance is
180ms at Full, opacity and transform only, no layout property animated
anywhere, and the three tiers resolve to 180/120/0ms with 6/0/0px of travel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5 committed 2026-08-19 19:48:08 +02:00
1 parent 12d0c10a51
commit 1e09f36afb
30 files changed
+697 -75

No files matched your search

+16 -3
View File
@@ -5,15 +5,28 @@ The attachment strip of a compose window. Rendered with the window and again
on its own after every upload/removal — compose_controller swaps it into the
"attachments" target — so it must stay a standalone fragment.
Variables: message
`live` says whether this render is an UPDATE — the answer to an upload or a
removal, swapped in by compose_controller — or the copy that ships inside a
window that is itself arriving. Only the first animates. The window carries its
own entrance, and a staggered strip playing inside it is two animations for one
arrival; the three controller renders take the default and get the chips one
after another, which is the case that needed marking. An attachment that
appears with no sign it arrived is an attachment people upload twice.
Variables: message, live (default true)
#}
{% set parts = message.messageParts|filter(part => not part.isInline) %}
{% set live = live|default(true) %}
{% if parts|length > 0 %}
<div class="flex flex-wrap gap-2 px-4 pb-2">
{# Stagger, so three files dropped at once read as a list arriving rather
than as a slab appearing. motion.css caps the delay at eight, which is
well past the point where a compose window has other problems. #}
<div class="flex flex-wrap gap-2 px-4 pb-2" {% if live %}data-enter-stagger{% endif %}>
{% for part in parts %}
{% set kb = (part.size ?: 0) / 1024 %}
<span class="inline-flex items-center gap-2 pl-2.5 pr-1.5 py-1 rounded-lg text-xs
<span {% if live %}data-enter="rise"{% endif %}
class="inline-flex items-center gap-2 pl-2.5 pr-1.5 py-1 rounded-lg text-xs
bg-sunken border border-line text-ink-soft">
<i class="fa-solid fa-paperclip text-[10px] text-ink-faint" aria-hidden="true"></i>
{# `download` for the same reason as in mail/_attachment_chip:
+13
View File
@@ -24,11 +24,24 @@ anchors it above a toolbar block that spans the window and never scrolls (the
button itself lives in a sideways scroller and would drag the popover off
screen with it); the signature editor anchors it under its own small bar.
The ENTRANCE is the caller's too, and for exactly the same reason as the
positioning: a popover should travel away from the edge it is pinned to, and
the two hosts pin it to opposite ones. The compose window hangs it above the
toolbar and passes `rise`; the signature editor drops it under its own bar and
takes the default, which is the direction-neutral fade rather than a guess that
would be wrong half the time.
Only the popover itself is marked. The two faces below toggle `hidden` as well,
and marking them would play an entrance every time Change flips between them —
which is one object changing what it says, not a second one arriving.
Variables:
id string unique suffix for the two field ids
class string positioning and width classes for the popover box
enter string motion.css entrance name (default 'fade')
#}
<div data-compose--compose-toolbar-target="linkPopover" hidden
data-enter="{{ enter|default('fade') }}"
role="dialog"
aria-label="{{ 'compose.link.dialog'|trans }}"
data-action="keydown->compose--compose-toolbar#linkKeydown"
+4 -1
View File
@@ -5,7 +5,10 @@ Reachable only on a fresh install — which is exactly when it used to be a 500,
because Message::setAccount() is not nullable and defaultAccount() had nothing
to give it. A dead end with a way out beats a stack trace.
#}
<div class="p-8 text-center">
{# Arrives in the dock, in the slot the compose window would have filled, so it
arrives the same way the window does. Anything else and pressing Compose
animates on the days it works and blinks on the days it does not. #}
<div data-enter="fade" class="p-8 text-center">
<div class="mx-auto w-11 h-11 rounded-full bg-sunken flex items-center justify-center">
<i class="fa-solid fa-envelope text-ink-faint" aria-hidden="true"></i>
</div>
+9 -1
View File
@@ -154,8 +154,16 @@ Variables: message, params (from the including context); placement.
{# See the note at the top for why the direction and the anchor both flip
with the placement. compose-scheduled-send.spec.ts measures the box that
comes out of it at 393px and at 320px. #}
comes out of it at 393px and at 320px.
The ENTRANCE flips with them, for the same reason: a menu should travel
away from the pill it came out of. The bar pill opens UPWARD, so the menu
rises into place from where the pill is; the header pill opens DOWNWARD,
so it comes down from above. A menu that slides the wrong way reads as a
second, unrelated thing appearing beside the one that was asked for.
Six pixels at Full and none at all at Minimal. #}
<div data-ui--dropdown-target="menu" hidden
data-enter="{{ header ? 'slide-down' : 'rise' }}"
class="absolute z-20 w-64 max-w-[calc(100vw-2rem)] popover py-1
{{ header ? 'top-full mt-1 right-0 left-auto' : 'bottom-full mb-1 left-0 right-auto' }}">
+7 -1
View File
@@ -85,8 +85,14 @@ Variables: form, compact (default false)
that came from the server reads the same as one the window
caught itself. It travels WITH the row: in a compact header the
address block below is folded away, and a refusal nobody can see is
the oldest bug in this window. #}
the oldest bug in this window.
It is shown and hidden by a `hidden` class, and nothing else on screen
moves when it is — press Send with no recipient and a line of red simply
is there. A fade rather than a travel: the row above it does not shift,
so there is no edge for the refusal to have come from. #}
<div data-compose--compose-target="errors"
data-enter="fade"
class="{{ compact ? '' : 'px-4' }} {{ form.toAddresses.vars.errors|length > 0 ? 'py-1' : 'hidden' }}"
role="alert" aria-live="polite">
<p class="text-xs text-danger">
+67 -3
View File
@@ -123,6 +123,25 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
data-compose--compose-send-url-value="{{ path('app_compose_mail_send_draft', params|merge({'id': message.id})) }}"
data-compose--compose-schedule-url-value="{{ path('app_compose_mail_schedule_draft', params|merge({'id': message.id})) }}"
{% endif %}
{# ── The window arriving ──────────────────────────────────
In the dock, at the foot of a thread, or back again after a
cancelled send — every one of those inserts this element into a
page that painted long ago, which is what motion.js is for.
FADE, not the `pop` motion.css reserves for window-sized things,
and the departure is deliberate. Below md this element IS the
screen (see the max-md rules just below), so scaling it from 0.985
draws a hairline of the mail list around all four edges for a
quarter of a second — the window reads as peeling off the viewport
rather than as arriving in it. A transform here is also inherited
by everything inside the window while it runs, including the caret
the user is already typing into and every box the mobile specs
measure against the viewport.
It does not gate anything: opacity is composited, connect() moves
focus on the same frame it always did, and the window takes
keystrokes for the whole 180ms. #}
data-enter="fade"
class="compose-window flex flex-col
{% if inline %}
w-full rounded-pane border border-line bg-surface shadow-sm mt-3
@@ -337,7 +356,14 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
</button>
</div>
{# Comes down out of the row above it, which is where it is
anchored (`top-full`). The `hidden` class is display, and a
CSS animation starts afresh every time an element stops being
display:none — so this replays on each open with no
JavaScript, and never fires on window open, where it is
folded away. #}
<div data-compose--compose-target="fromDropdown"
data-enter="slide-down"
class="hidden absolute left-4 right-4 top-full z-20 rounded-pane overflow-hidden
bg-pane-soft
backdrop-blur-sm
@@ -465,7 +491,12 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
{# ── Attachments ──────────────────────────────────────── #}
<div data-compose--compose-target="attachments" class="shrink-0">
{{ include('compose/_attachments.html.twig', { message: message }) }}
{# `live: false` — the chips are already part of the window
that is arriving, and animating them here would play a
staggered entrance inside an element playing its own. The
strip animates when it is REPLACED, which is the case worth
marking; see the partial. #}
{{ include('compose/_attachments.html.twig', { message: message, live: false }) }}
</div>
{# ── Toolbar ──────────────────────────────────────────── #}
@@ -492,10 +523,16 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
hosts disagree about: here it is anchored to the toolbar
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. #}
off whichever edge the button wrapped to.
`enter: 'rise'` for the same reason it is anchored upwards:
the popover should travel away from the toolbar it is pinned
above. The signature editor pins it the other way up and takes
the partial's direction-neutral default. #}
{{ 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',
enter: 'rise',
}, with_context = false) }}
{# ── Plain-text warning ───────────────────────────────
@@ -506,7 +543,12 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
_setPlainText) — but a native dialog is the one thing in this
window the theme never reached, and on a phone it is a
system sheet over an app that was mid-sentence. #}
{# Rises out of the toolbar it is pinned above, because this
panel is the whole answer to pressing "Plain text": with
nothing to say it arrived, a question simply is there over
the message, connected to no button anybody pressed. #}
<div data-compose--compose-target="plainWarning" hidden
data-enter="rise"
role="alertdialog"
aria-labelledby="{{ form.vars.id }}_plain_warning_title"
aria-describedby="{{ form.vars.id }}_plain_warning_body"
@@ -551,7 +593,11 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
second dialog appeared after the first had been dismissed with
no way back. The reasons are listed together and answered
once. #}
{# Same entrance and same reason as the panel above, at a worse
moment: the user has just pressed Send and the mail did not
go, so this panel is the only thing that happened. #}
<div data-compose--compose-target="sendWarning" hidden
data-enter="rise"
role="alertdialog"
aria-labelledby="{{ form.vars.id }}_send_warning_title"
aria-describedby="{{ form.vars.id }}_send_warning_body"
@@ -830,7 +876,16 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
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. #}
{# FADE, where every other menu in this bar rises, and the
exception is load-bearing: compose--emoji-picker
measures this panel's own bottom edge against the
window's top on the frame after it opens, to work out
the height cap. A 6px travel is 6px of extra room in
that sum, and the panel would be capped to overhang
the window it belongs to — the exact bug _fitToWindow
exists to fix. Opacity is measured by nobody. #}
<div data-ui--dropdown-target="menu" hidden
data-enter="fade"
data-compose--emoji-picker-target="panel"
class="emoji-panel absolute bottom-full mb-2
left-1/2 -translate-x-1/2 w-[calc(100%-1rem)]
@@ -931,8 +986,16 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
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. #}
box against the viewport at 393px and at 320px.
`rise`, not the `slide-down` ui--dropdown gives a menu
that does not name its own: this one is pinned
`bottom-full` and comes UP out of the action bar, and
a menu sliding down into the button that opened it
reads as the wrong thing arriving. Same for the
service picker below. #}
<div data-ui--dropdown-target="menu" hidden
data-enter="rise"
class="absolute bottom-full mb-2 left-2 right-2
md:left-auto md:right-3 md:w-64 z-30 popover py-1">
@@ -1013,6 +1076,7 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
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
data-enter="rise"
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 %}