mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
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>
61 lines
2.3 KiB
JavaScript
61 lines
2.3 KiB
JavaScript
import { Controller } from "@hotwired/stimulus"
|
|
import { leave } from "../../motion.js"
|
|
|
|
/**
|
|
* A toast: it arrives on its own, and it leaves on its own.
|
|
*
|
|
* The entrance is declared in the markup (`data-enter="rise"` in
|
|
* _partials/_toast.html.twig) and played by motion.js, because a toast almost
|
|
* always arrives in a turbo-stream — inserted into a document that painted
|
|
* minutes ago, which is precisely the case CSS alone cannot see.
|
|
*
|
|
* The exit is the one this controller has to drive, and it is the reason
|
|
* motion.css keeps an exit animation at all. Everything else in plMail leaves
|
|
* by a jump cut on purpose — an exit animation is time between asking for a
|
|
* thing and getting it. A toast is the exception: nobody asked for it to go, it
|
|
* goes by itself four seconds later, and a strip of text that was in the corner
|
|
* of the eye and then simply is not reads as a glitch rather than as a
|
|
* dismissal.
|
|
*
|
|
* This used to hand-roll both halves in inline styles — opacity and a
|
|
* translateY set from JavaScript, with hardcoded 200ms transitions that no
|
|
* motion setting and no `prefers-reduced-motion` could reach. Both are now the
|
|
* shared tokens, so the toast is as calm as the rest of the app is asked to be.
|
|
*/
|
|
export default class extends Controller {
|
|
static values = { duration: { type: Number, default: 4000 } }
|
|
static targets = ['countdown']
|
|
|
|
connect() {
|
|
this._remaining = Math.round(this.durationValue / 1000)
|
|
this._updateCountdown()
|
|
|
|
this._interval = setInterval(() => {
|
|
this._remaining--
|
|
this._updateCountdown()
|
|
if (this._remaining <= 0) this.dismiss()
|
|
}, 1000)
|
|
}
|
|
|
|
disconnect() {
|
|
clearInterval(this._interval)
|
|
}
|
|
|
|
_updateCountdown() {
|
|
if (this.hasCountdownTarget) {
|
|
this.countdownTarget.textContent = this._remaining
|
|
}
|
|
}
|
|
|
|
dismiss() {
|
|
clearInterval(this._interval)
|
|
|
|
// leave() removes the node itself once the animation is done, and
|
|
// removes it in the same tick when motion is off — the `none` tier or
|
|
// reduced motion. That matters beyond taste: the old code waited for a
|
|
// `transitionend` that a zero-duration transition never fires, so a
|
|
// toast could outlive its own dismissal indefinitely.
|
|
leave(this.element)
|
|
}
|
|
}
|