mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
feat(motion): new mail drops into a gap the list opens for it
Your configuration from the motion console, made real. mail row slide-down, 600ms, 48px, spring room 200ms handoff 200ms overflow the departing row fades where it stood The row is now the only surface in plMail with numbers of its own, and MotionLevel says why at length: it is the only animation here carrying INFORMATION rather than reassurance. Everything else says "this is here now", which the eye needs briefly and then resents. This one says "this one is new", and it is affordable at that length for two reasons that both have to hold — it is rare, because nothing in a redraw plays it, and it gates nothing, because the row is readable and clickable from its first frame. The gap is the interesting half. Nothing is ever inserted into this list: it is refetched and morphed, so by the time anything can be animated the new row is already in place and every row below it is already one row lower. There is no gap to open. So it is played backwards — measure where every row was, and afterwards put the survivors back and release them. What the eye sees is rows travelling down to make space, which is a true account of what happened, arrived at from the wrong end. Two corrections to the morph that shipped this morning, both found by this work rather than by the tests that were meant to cover them: The list was nesting a duplicate <ul> inside itself, one level deeper per refresh. Idiomorph treats the element you hand it as CONTENT, so passing the fresh <ul> asked for a list containing a list. Every assertion still passed — the rows morphed correctly and simply lived a level further down. There is an assertion on the shape now. Exempting the `hidden` attribute on an open menu was too narrow: the snooze menu also hides individual entries and un-hides them on open, so the menu stayed standing with its contents blanked. An open menu is now skipped whole. Minimal does not scale the row down proportionally, it opts out — a shortened version of a gesture whose whole point is its length is not a smaller gesture, it is a worse one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3cf3daf080
commit
4c7752006b
8 files changed
+637
-35
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
import { Idiomorph } from "idiomorph";
|
||||
import { leave, motionIsOff } from "../../motion.js";
|
||||
import { leave, makeRoom, motionIsOff } from "../../motion.js";
|
||||
|
||||
// MailboxSpecialUse values (see App\Domain\Enum\MailboxSpecialUse) mapped to
|
||||
// the data-sync-scope tokens used by the list templates.
|
||||
@@ -94,17 +94,39 @@ const MORPHED_REGION = "rows";
|
||||
const CLIENT_OWNED = new Set(["data-entered", "data-enter", "data-leaving"]);
|
||||
|
||||
/**
|
||||
* A menu whose open-or-closed state belongs to whoever opened it.
|
||||
* A menu, which stops being the server's business the moment it is opened.
|
||||
*
|
||||
* ui--dropdown shows a menu by clearing `hidden`, and the server always renders
|
||||
* it set. Preserving the node is therefore not enough on its own: the morph
|
||||
* would faithfully put `hidden` back and close a menu somebody is reading, in
|
||||
* the same breath as fixing the older bug where the whole thing was destroyed.
|
||||
* Preserving the node is not enough on its own. ui--dropdown shows a menu by
|
||||
* clearing `hidden`, and the server always renders it set, so a faithful morph
|
||||
* closes a menu somebody is reading in the same breath as fixing the older bug
|
||||
* where the whole thing was destroyed.
|
||||
*
|
||||
* Scoped to the menu element rather than made a global exception for `hidden`,
|
||||
* which the server does legitimately own nearly everywhere else.
|
||||
* `hidden` on the menu itself was the obvious exception to make and the wrong
|
||||
* one: the snooze menu ALSO hides individual entries and un-hides them on open,
|
||||
* so exempting one attribute on one element left the menu standing with its
|
||||
* contents blanked. The rule that holds is the broader one — an open menu is
|
||||
* under somebody's pointer, and nothing a background sync has to say about it
|
||||
* can be more important than that. It is skipped whole, children included, and
|
||||
* becomes the server's again the moment it closes.
|
||||
*/
|
||||
const OPEN_STATE_IS_THEIRS = '[data-ui--dropdown-target="menu"]';
|
||||
const MENU = '[data-ui--dropdown-target="menu"]';
|
||||
|
||||
/**
|
||||
* One row of the list, as opposed to any of the hundreds of nodes inside one.
|
||||
*
|
||||
* The morph's add and remove hooks fire for every node they touch, most of
|
||||
* which are fragments being brought up to date — a span whose text changed, a
|
||||
* star that is no longer there. Both hooks below act only on whole rows, and
|
||||
* this is how they tell.
|
||||
*
|
||||
* Matched by selector rather than by asking whether the node's parent is the
|
||||
* list. That test looks equivalent and is not: Idiomorph inserts and morphs
|
||||
* through a proxy parent, so a node can be handed to a callback before it is
|
||||
* where it will end up, and `parentElement === live` is false for a row that is
|
||||
* unmistakably a row. Found the hard way, by an entrance that never got its
|
||||
* delay.
|
||||
*/
|
||||
const ROW = 'li[data-controller="mail--message-row"]';
|
||||
|
||||
/** The row checkboxes, whose `value` is the thread id. */
|
||||
const ROW_SELECT = "input[data-thread-select]";
|
||||
@@ -583,27 +605,62 @@ export default class extends Controller {
|
||||
* is the server's, which knows nothing about what is ticked.
|
||||
*/
|
||||
_morphRows(live, incoming) {
|
||||
Idiomorph.morph(live, incoming, {
|
||||
// Brackets the morph: the measuring has to happen while the old list is
|
||||
// still standing, and the playing after the new one has been laid out.
|
||||
// See motion.js#makeRoom — a list that is replaced rather than inserted
|
||||
// into has no other moment at which a gap could be shown opening.
|
||||
const room = makeRoom(live);
|
||||
|
||||
// `incoming.innerHTML`, not `incoming`. The second argument is the new
|
||||
// CONTENT, and an element handed over whole is content — so passing the
|
||||
// fresh <ul> asks for a list containing a list, and gets one: a
|
||||
// duplicate <ul data-list-region="rows"> nested inside the live one,
|
||||
// one level deeper on every refresh. It hid well, because the rows
|
||||
// still morphed correctly and kept their identity; they simply lived
|
||||
// one level further down than anything looking for them expected.
|
||||
Idiomorph.morph(live, incoming.innerHTML, {
|
||||
morphStyle: "innerHTML",
|
||||
callbacks: {
|
||||
beforeAttributeUpdated: (name, node) => this._serverOwns(name, node),
|
||||
beforeNodeRemoved: (node) => this._rowLeaving(node, live),
|
||||
beforeAttributeUpdated: (name) => false === CLIENT_OWNED.has(name),
|
||||
beforeNodeMorphed: (node) => this._mayMorph(node),
|
||||
beforeNodeRemoved: (node) => this._rowLeaving(node, room),
|
||||
afterNodeAdded: (node) => this._rowArrived(node),
|
||||
},
|
||||
});
|
||||
|
||||
room.play();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the morph may write this attribute, or whether the client is
|
||||
* holding something in it that the server cannot know about.
|
||||
* A row the morph actually inserted — new mail, or the first sight of a
|
||||
* thread that has moved onto this page.
|
||||
*
|
||||
* See CLIENT_OWNED and OPEN_STATE_IS_THEIRS.
|
||||
* Marked, and only marked. What the mark buys is the pause between the gap
|
||||
* finishing opening and the row falling into it, which lives in motion.css
|
||||
* as an animation-delay. It has to be an attribute rather than a call
|
||||
* because the entrance has not started yet: motion.js gives this row its
|
||||
* `data-enter` on the next microtask, and a delay applied after that would
|
||||
* be applied to an animation already running.
|
||||
*
|
||||
* Whole rows only — see ROW.
|
||||
*/
|
||||
_serverOwns(name, node) {
|
||||
if (CLIENT_OWNED.has(name)) {
|
||||
return false;
|
||||
_rowArrived(node) {
|
||||
if (true === node.matches?.(ROW)) {
|
||||
node.dataset.enterDelay = "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this node and everything inside it may be brought up to date.
|
||||
*
|
||||
* Only one thing says no, and see MENU for why: a dropdown that is open.
|
||||
*/
|
||||
_mayMorph(node) {
|
||||
if (true !== node.matches?.(MENU)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return "hidden" !== name || false === node.matches?.(OPEN_STATE_IS_THEIRS);
|
||||
return node.hidden;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -619,13 +676,10 @@ export default class extends Controller {
|
||||
* The marker is what stops a second refresh, arriving while the first
|
||||
* fade is still running, from starting the same departure twice.
|
||||
*/
|
||||
_rowLeaving(node, live) {
|
||||
// Only a row, and only a row of THIS list. The hook fires for every
|
||||
// node the morph discards, most of which are fragments inside a row
|
||||
// being brought up to date — a span whose text changed, a star that is
|
||||
// no longer there. Holding one of those back would leave the shrapnel
|
||||
// of an edit on screen for as long as the animation lasts.
|
||||
if (node.parentElement !== live || undefined === node.dataset) {
|
||||
_rowLeaving(node, room) {
|
||||
// Whole rows only — see ROW. Holding back a fragment of an edit would
|
||||
// leave the shrapnel of it on screen for as long as the fade lasts.
|
||||
if (true !== node.matches?.(ROW)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -642,7 +696,12 @@ export default class extends Controller {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The id goes first, and it matters. A row held back for its fade is
|
||||
// Pinned where it was standing before it is taken out of flow, so
|
||||
// the rows below can close over it while it fades rather than waiting
|
||||
// for it to go. This reads the id, so it goes before the id is dropped.
|
||||
room.holding(node);
|
||||
|
||||
// The id goes second, and it matters. A row held back for its fade is
|
||||
// still a child of the list, so the NEXT morph can match it by id — and
|
||||
// a thread that left the page and came straight back (a reply landing
|
||||
// in it moments later) would be morphed into a node that is mid-exit:
|
||||
|
||||
@@ -238,6 +238,126 @@ export function leave(element, done) {
|
||||
setTimeout(finish, 400);
|
||||
}
|
||||
|
||||
/**
|
||||
* Milliseconds from a CSS duration, for the two places JS has to out-wait one.
|
||||
*
|
||||
* Both `ms` and `s` are legal in a custom property and the browser hands back
|
||||
* whichever was written, so neither can be assumed.
|
||||
*/
|
||||
function milliseconds(value) {
|
||||
const number = Number.parseFloat(value);
|
||||
|
||||
if (Number.isNaN(number)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return value.trim().endsWith("ms") ? number : number * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make room in a list that was never inserted into.
|
||||
*
|
||||
* The mail list is not built up row by row. mail--mail-pane fetches the whole
|
||||
* list and morphs it, so by the time anything can be animated the new mail is
|
||||
* already in place and every row below it is already one row lower. There is no
|
||||
* moment at which a gap opens, and no gap to animate.
|
||||
*
|
||||
* So the gap is played backwards. Measure where every row is BEFORE the morph;
|
||||
* afterwards, put the survivors back where they were with a transform and then
|
||||
* release them. What the eye sees is rows travelling down to make space — which
|
||||
* is a true account of what happened, arrived at from the wrong end.
|
||||
*
|
||||
* Used like this, because the measuring has to bracket the morph:
|
||||
*
|
||||
* const room = makeRoom(list);
|
||||
* ...morph, calling room.holding(row) for each row being dropped...
|
||||
* room.play();
|
||||
*
|
||||
* Transform only. Nothing here touches layout, so fifty rows travelling costs
|
||||
* no reflows and every one of them stays clickable the whole way — a row is
|
||||
* hit-tested where it is drawn, not where it started.
|
||||
*/
|
||||
export function makeRoom(container) {
|
||||
if (motionIsOff()) {
|
||||
return { holding: () => {}, play: () => {} };
|
||||
}
|
||||
|
||||
// Anything still travelling from a previous refresh is finished now, so
|
||||
// that the positions below are read from one settled layout rather than
|
||||
// from a list in two states at once.
|
||||
settle(container);
|
||||
|
||||
const was = new Map();
|
||||
|
||||
for (const row of container.children) {
|
||||
if ("" !== row.id) {
|
||||
was.set(row.id, row.offsetTop);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* A row that is leaving, caught before the morph takes it out.
|
||||
*
|
||||
* Pinned where it was standing and taken out of flow, so the list can
|
||||
* close over it while it fades. Its id goes with it — see
|
||||
* mail--mail-pane#_rowLeaving for why a departing row must stop being
|
||||
* addressable.
|
||||
*/
|
||||
holding(row) {
|
||||
row.style.top = `${was.get(row.id) ?? row.offsetTop}px`;
|
||||
},
|
||||
|
||||
play() {
|
||||
const styles = getComputedStyle(container);
|
||||
const duration = styles.getPropertyValue("--motion-room").trim();
|
||||
const ease = styles.getPropertyValue("--motion-row-ease").trim();
|
||||
const moved = [];
|
||||
|
||||
for (const row of container.children) {
|
||||
const before = was.get(row.id);
|
||||
|
||||
if (undefined === before) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const travelled = before - row.offsetTop;
|
||||
|
||||
if (0 !== travelled) {
|
||||
row.style.transform = `translateY(${travelled}px)`;
|
||||
moved.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
if (0 === moved.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Two frames, not one. A single frame lets the browser coalesce the
|
||||
// transform above with the one below and transition nothing at all,
|
||||
// which is the oldest bug in this technique.
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
moved.forEach((row) => {
|
||||
row.style.transition = `transform ${duration} ${ease}`;
|
||||
row.style.transform = "";
|
||||
});
|
||||
}));
|
||||
|
||||
setTimeout(() => settle(container), milliseconds(duration) + 120);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Take the inline remains of a finished journey back off. */
|
||||
function settle(container) {
|
||||
for (const row of container.children) {
|
||||
if ("" !== row.style.transform || "" !== row.style.transition) {
|
||||
row.style.transform = "";
|
||||
row.style.transition = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch for arrivals.
|
||||
*
|
||||
|
||||
@@ -70,6 +70,12 @@
|
||||
--motion-scale: 0.985;
|
||||
--motion-stagger: 22ms;
|
||||
--motion-ease: cubic-bezier(0.22, 0.68, 0.32, 1);
|
||||
|
||||
--motion-row-base: 600ms;
|
||||
--motion-row-lift: 48px;
|
||||
--motion-row-ease: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--motion-room: 200ms;
|
||||
--motion-room-handoff: 200ms;
|
||||
}
|
||||
|
||||
/* The tier the server asked for. Belt and braces with the inline custom
|
||||
@@ -82,6 +88,12 @@
|
||||
--motion-lift: 0px;
|
||||
--motion-scale: 1;
|
||||
--motion-stagger: 0ms;
|
||||
|
||||
--motion-row-base: 120ms;
|
||||
--motion-row-lift: 0px;
|
||||
--motion-row-ease: cubic-bezier(0.22, 0.68, 0.32, 1);
|
||||
--motion-room: 120ms;
|
||||
--motion-room-handoff: 0ms;
|
||||
}
|
||||
|
||||
[data-motion="none"] {
|
||||
@@ -91,6 +103,12 @@
|
||||
--motion-lift: 0px;
|
||||
--motion-scale: 1;
|
||||
--motion-stagger: 0ms;
|
||||
|
||||
--motion-row-base: 0s;
|
||||
--motion-row-lift: 0px;
|
||||
--motion-row-ease: cubic-bezier(0.22, 0.68, 0.32, 1);
|
||||
--motion-room: 0s;
|
||||
--motion-room-handoff: 0ms;
|
||||
}
|
||||
|
||||
/* ── Entrances ───────────────────────────────────────────────────────────────
|
||||
@@ -204,5 +222,65 @@
|
||||
--motion-lift: 0px !important;
|
||||
--motion-scale: 1 !important;
|
||||
--motion-stagger: 0ms !important;
|
||||
|
||||
--motion-row-base: 0s !important;
|
||||
--motion-row-lift: 0px !important;
|
||||
--motion-room: 0s !important;
|
||||
--motion-room-handoff: 0ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── New mail arriving ───────────────────────────────────────────────────────
|
||||
* The mail list is the only surface with numbers of its own, and everything
|
||||
* that makes it different is in this block.
|
||||
*
|
||||
* The tokens are redefined on the ROWS rather than on the list, and the
|
||||
* distinction is not cosmetic: the <ul> plays its own fade when a whole new
|
||||
* list arrives — a folder, a search, the next page — and that fade is an
|
||||
* ordinary 180ms one. Redefining --motion-base on the list itself would give
|
||||
* the list six hundred milliseconds too, which is a folder change that takes
|
||||
* most of a second to admit it happened.
|
||||
*
|
||||
* Three things happen when new mail lands, in this order:
|
||||
*
|
||||
* 1 the rows below travel down to where they now are --motion-room
|
||||
* 2 nothing, while that finishes --motion-room-handoff
|
||||
* 3 the new row falls into the gap --motion-row-base
|
||||
*
|
||||
* Step 1 is the one that needs explaining. Nothing is ever inserted into this
|
||||
* list: mail--mail-pane fetches the whole list and morphs it, so by the time
|
||||
* anything could animate, the new row is already in place and everything below
|
||||
* it is already one row lower. There is no gap to open. So the survivors are
|
||||
* put BACK where they were and released — see motion.js#makeRoom — which is
|
||||
* the only honest way to show a list making room it never made. */
|
||||
[data-list-region="rows"] {
|
||||
/* The containing block for a row on its way out. See below. */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-list-region="rows"] > * {
|
||||
--motion-base: var(--motion-row-base);
|
||||
--motion-lift: var(--motion-row-lift);
|
||||
--motion-ease: var(--motion-row-ease);
|
||||
}
|
||||
|
||||
/* Step 2. Written by mail--mail-pane on the rows a morph actually inserted,
|
||||
and only those: a row present at first paint has no gap to wait for, and a
|
||||
delay there would just be the list appearing late. `backwards` fill (set on
|
||||
[data-enter] above) is what keeps the row invisible rather than fully drawn
|
||||
while it waits. */
|
||||
[data-list-region="rows"] > [data-enter-delay] {
|
||||
animation-delay: var(--motion-room-handoff);
|
||||
}
|
||||
|
||||
/* A row that fell off the end — the fifty-first after new mail arrived, or one
|
||||
that no longer belongs in the open folder.
|
||||
Taken out of flow so the list closes over it while it fades, rather than
|
||||
holding its space open for the length of the fade and then snapping shut.
|
||||
`top` is written inline by motion.js, because only JavaScript knows where the
|
||||
row was standing before the morph took it out of the list. */
|
||||
[data-list-region="rows"] > [data-leaving] {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
@@ -96,14 +96,27 @@ they are sending, not what they set on this page.
|
||||
**Motion** is **Full**, **Minimal** or **None**, and it decides how much the interface moves when
|
||||
something appears — a message arriving in the list, the compose window opening, a menu dropping down.
|
||||
|
||||
- **Full** — things arrive from somewhere and settle. Nothing takes longer than a quarter of a
|
||||
second, and nothing waits for an animation before you can use it: a row is clickable while it is
|
||||
- **Full** — things arrive from somewhere and settle. Almost nothing takes longer than a quarter of
|
||||
a second, and nothing waits for an animation before you can use it: a row is clickable while it is
|
||||
still fading in.
|
||||
- **Minimal** — the same cues, as a fade, with nothing moving and nothing displaced. Slightly faster
|
||||
than Full, because without travel there is less for the eye to follow and the same duration starts
|
||||
to read as a lag.
|
||||
- **None** — exactly what plMail did before any of this existed.
|
||||
|
||||
### New mail is the exception
|
||||
|
||||
One thing takes noticeably longer than everything else, and it is deliberate: a conversation that has
|
||||
genuinely just arrived drops into the list from above, the rows below it moving down to let it in.
|
||||
The whole gesture runs about eight hundred milliseconds — several times anything else in plMail.
|
||||
|
||||
It is affordable because it is *rare*. The list is redrawn constantly, after every star, archive,
|
||||
bulk action and sync, and none of that plays this. Only mail that has never been on your screen
|
||||
before does, which on a normal mailbox is a handful of times an hour. And it gates nothing — the row
|
||||
can be read, clicked and selected from its first frame.
|
||||
|
||||
At **Minimal** it collapses to the same short fade as everything else: no drop, no travel, no wait.
|
||||
|
||||
**If your system asks for reduced motion, that wins** — whatever is set here, and without asking.
|
||||
Somebody who has told their operating system that movement makes them ill has not asked plMail for
|
||||
its opinion.
|
||||
|
||||
@@ -26,12 +26,16 @@ namespace App\Domain\Enum\Theme;
|
||||
* hardcoded milliseconds — and so a reviewer can see the whole motion budget
|
||||
* of the application in one screenful.
|
||||
*
|
||||
* **Nothing here exceeds 260ms.** Past roughly a quarter second a transition
|
||||
* stops reading as "this moved" and starts reading as "I am waiting for this",
|
||||
* which is the failure mode that makes people turn animation off. Enter
|
||||
* animations are also never allowed to gate interaction: a row is clickable
|
||||
* while it is still fading in, because the alternative is a beautiful
|
||||
* **Nothing in the general vocabulary exceeds 260ms.** Past roughly a quarter
|
||||
* second a transition stops reading as "this moved" and starts reading as "I am
|
||||
* waiting for this", which is the failure mode that makes people turn animation
|
||||
* off. Enter animations are also never allowed to gate interaction: a row is
|
||||
* clickable while it is still fading in, because the alternative is a beautiful
|
||||
* interface that feels slower than the one it replaced.
|
||||
*
|
||||
* The mail row breaks that ceiling on purpose, and it is the only thing that
|
||||
* does — see rowBase(). The rule above is about animations you meet constantly.
|
||||
* A genuinely new mail arriving is not one of those.
|
||||
*/
|
||||
enum MotionLevel: string
|
||||
{
|
||||
@@ -140,6 +144,114 @@ enum MotionLevel: string
|
||||
return 'cubic-bezier(0.22, 0.68, 0.32, 1)';
|
||||
}
|
||||
|
||||
/**
|
||||
* ── New mail arriving ────────────────────────────────────────────────
|
||||
*
|
||||
* The one surface with its own numbers rather than the shared ones, and
|
||||
* the reason is that it is the only animation in plMail carrying
|
||||
* INFORMATION. Every other entrance says "this is here now", which the eye
|
||||
* needs for a fraction of a second and then resents. This one says "this
|
||||
* one is new" — a thing worth taking a moment over, because it fires only
|
||||
* when mail genuinely arrives, which on a real mailbox is a handful of
|
||||
* times an hour and not once a second.
|
||||
*
|
||||
* It is affordable at this length for two reasons and they both have to
|
||||
* hold. It is rare: nothing in a redraw plays it, which is what the id rule
|
||||
* in motion.js exists to guarantee. And it gates nothing: the row is
|
||||
* clickable, selectable and readable from its first frame, so the six
|
||||
* hundred milliseconds are never six hundred milliseconds of waiting.
|
||||
*
|
||||
* Minimal does not scale this down proportionally, it opts out: the row
|
||||
* takes the ordinary base and travels nowhere, like everything else at that
|
||||
* tier. A shortened version of a gesture whose whole point is its length is
|
||||
* not a smaller gesture, it is a worse one.
|
||||
*/
|
||||
public function rowBase(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Full => '600ms',
|
||||
self::Minimal => $this->base(),
|
||||
self::None => '0s',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How far a new row falls.
|
||||
*
|
||||
* Eight times the general lift, because it is doing a different job. Six
|
||||
* pixels is a hint that something moved; forty-eight is a distance the eye
|
||||
* can follow from somewhere to somewhere, which is what makes the row read
|
||||
* as having ARRIVED rather than as having been redrawn slightly wrong.
|
||||
*
|
||||
* The list clips it, so the row is seen entering from beyond the top edge
|
||||
* rather than materialising inside the list and sliding.
|
||||
*/
|
||||
public function rowLift(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Full => '48px',
|
||||
self::Minimal, self::None => '0px',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The one curve in plMail that overshoots.
|
||||
*
|
||||
* ease() argues against a spring, and that argument stands for everything
|
||||
* ease() covers: a fortieth-time interface should not bounce. This is the
|
||||
* exception the argument makes room for — the overshoot is what separates
|
||||
* "a row appeared at the top" from "a row DROPPED IN at the top", and being
|
||||
* unmistakable is the entire job here.
|
||||
*
|
||||
* At Minimal there is nowhere to overshoot from, so it falls back rather
|
||||
* than bouncing a zero-length travel.
|
||||
*/
|
||||
public function rowEase(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Full => 'cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
self::Minimal, self::None => $this->ease(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How long the rows below take to get out of the way.
|
||||
*
|
||||
* The list is not inserted into, it is replaced wholesale and then morphed
|
||||
* — see mail--mail-pane#_morphRows — so by the time anything can be
|
||||
* animated the new row is already in place and everything below it is
|
||||
* already one row lower. This is the duration of putting the survivors back
|
||||
* where they were and letting them travel to where they now are, which is
|
||||
* the only way to show a gap opening in a list nobody inserted into.
|
||||
*/
|
||||
public function room(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Full => '200ms',
|
||||
self::Minimal => $this->base(),
|
||||
self::None => '0s',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How long the new row waits before dropping into the gap.
|
||||
*
|
||||
* Sequential rather than simultaneous, and deliberately the full length of
|
||||
* the room: the gap finishes opening and only then does anything fall into
|
||||
* it. Overlapping the two is cheaper in wall-clock time and reads as one
|
||||
* blurred event; separating them reads as cause and effect, which is what
|
||||
* the whole gesture is claiming.
|
||||
*
|
||||
* Zero at Minimal, where there is no gap to wait for.
|
||||
*/
|
||||
public function roomHandoff(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Full => '200ms',
|
||||
self::Minimal, self::None => '0ms',
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether anything animates at all — the escape hatch JS reads. */
|
||||
public function animates(): bool
|
||||
{
|
||||
|
||||
@@ -53,6 +53,15 @@ final readonly class AppearanceRenderer
|
||||
'--motion-scale' => $appearance->motion->scaleFrom(),
|
||||
'--motion-stagger' => $appearance->motion->stagger(),
|
||||
'--motion-ease' => $appearance->motion->ease(),
|
||||
|
||||
// New mail arriving: the one surface with numbers of its own, and
|
||||
// the room the list makes for it. See MotionLevel::rowBase() for
|
||||
// why this is allowed to be the exception to everything above.
|
||||
'--motion-row-base' => $appearance->motion->rowBase(),
|
||||
'--motion-row-lift' => $appearance->motion->rowLift(),
|
||||
'--motion-row-ease' => $appearance->motion->rowEase(),
|
||||
'--motion-room' => $appearance->motion->room(),
|
||||
'--motion-room-handoff' => $appearance->motion->roomHandoff(),
|
||||
'--rgb-accent' => self::channels($appearance->accent),
|
||||
'--rgb-accent-ink' => self::contrastChannels($appearance->accent),
|
||||
'--scrim-alpha' => rtrim(rtrim(number_format($appearance->scrimAlpha, 3, '.', ''), '0'), '.') ?: '0',
|
||||
|
||||
@@ -180,7 +180,7 @@ Threads only. A bare Message row is a message, and only a conversation arrives.
|
||||
playing its own fade, and motion.js suppresses a child whose ancestor is
|
||||
announcing it. One arrival, one gesture. #}
|
||||
<li
|
||||
data-enter-new="rise"
|
||||
data-enter-new="slide-down"
|
||||
id="thread_{{ rowId }}"
|
||||
class="group relative hover:shadow-sm"
|
||||
data-controller="mail--message-row"
|
||||
|
||||
@@ -490,6 +490,13 @@ test.describe("mail UI actions", () => {
|
||||
);
|
||||
|
||||
expect(probes).toEqual(Array.from({ length: before }, (_, index) => index));
|
||||
|
||||
// Still one list. Handing the morph the fresh <ul> whole rather than
|
||||
// its children nests a duplicate inside the live one, a level deeper
|
||||
// every refresh — and every other assertion here still passes, because
|
||||
// the rows morph correctly and simply live further down. Cheap to
|
||||
// check, and it is the shape of the mistake, not one instance of it.
|
||||
await expect(page.locator('[data-list-region="rows"]')).toHaveCount(1);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -519,6 +526,210 @@ test.describe("mail UI actions", () => {
|
||||
await expect(menu.getByText("Later today")).toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* New mail arriving, staged.
|
||||
*
|
||||
* There is no way to make real mail land mid-test, so the refresh the pane
|
||||
* asks for on its own is answered with a list that has one more row in it
|
||||
* than the one on screen — which is precisely what a sync that found
|
||||
* something looks like from the browser's side.
|
||||
*
|
||||
* The row is copied rather than composed, so it carries every attribute the
|
||||
* real template emits and the test cannot drift away from it. Only the id
|
||||
* changes, because the id is the entire question: it is what tells the page
|
||||
* this is an arrival and not a redraw.
|
||||
*/
|
||||
const arrivingMail = async (page: Page) => {
|
||||
await page.route("**/mail/inbox**", async (route) => {
|
||||
if (route.request().headers()["x-list-fragment"] === undefined) {
|
||||
return route.continue();
|
||||
}
|
||||
|
||||
const response = await route.fetch();
|
||||
const body = await response.text();
|
||||
|
||||
// `<li` with the boundary, or this finds `<link>` in the head —
|
||||
// which produced a fixture that duplicated half the document and
|
||||
// sent a morph bug hunt after a bug that was in the test.
|
||||
const opens = body.search(/<li[\s>]/);
|
||||
const closes = body.indexOf("</li>", opens) + "</li>".length;
|
||||
const row = body.slice(opens, closes);
|
||||
|
||||
return route.fulfill({
|
||||
response,
|
||||
body:
|
||||
body.slice(0, opens) +
|
||||
row.replace(/id="thread_\d+"/, 'id="thread_99000001"') +
|
||||
body.slice(opens),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Records the movement rather than catching it mid-flight.
|
||||
*
|
||||
* Sampling a transform after the fact is a race with the animation it is
|
||||
* trying to observe, and a test that has to be quick enough is a test that
|
||||
* fails on a loaded machine. These listeners fire when the browser starts
|
||||
* the work, whenever that is, and the assertions read the tally afterwards.
|
||||
*/
|
||||
const recordMotion = (page: Page) =>
|
||||
page.evaluate(() => {
|
||||
const seen = { moved: 0, entered: [] as string[] };
|
||||
|
||||
Object.assign(window, { __motion: seen });
|
||||
|
||||
document.addEventListener(
|
||||
"transitionrun",
|
||||
(event) => {
|
||||
if ((event as TransitionEvent).propertyName === "transform") seen.moved++;
|
||||
},
|
||||
true,
|
||||
);
|
||||
document.addEventListener(
|
||||
"animationstart",
|
||||
(event) => {
|
||||
seen.entered.push((event as AnimationEvent).animationName);
|
||||
},
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The list makes room for mail it was handed already in place.
|
||||
*
|
||||
* Nothing is ever inserted into this list — the whole thing is refetched
|
||||
* and morphed, so the new row arrives with every row below it already one
|
||||
* row lower. The rows below therefore have to be put BACK and released, and
|
||||
* that is what `transitionrun` on `transform` is evidence of: they moved,
|
||||
* which they could only do if something replayed the gap.
|
||||
*/
|
||||
test("new mail arrives into a gap the list opens for it", async ({ page }) => {
|
||||
await page.goto("/mail/inbox");
|
||||
await expectSeededRows(page);
|
||||
|
||||
await recordMotion(page);
|
||||
await arrivingMail(page);
|
||||
await syncFired(page);
|
||||
|
||||
const arrival = page.locator("#thread_99000001");
|
||||
await expect(arrival).toHaveCount(1);
|
||||
|
||||
// Its entrance, and the pause before it. The delay attribute is written
|
||||
// only onto rows a morph actually inserted, which is what keeps a plain
|
||||
// page load from starting late.
|
||||
await expect(arrival).toHaveAttribute("data-enter", "slide-down");
|
||||
await expect(arrival).toHaveAttribute("data-enter-delay", "");
|
||||
|
||||
await expect
|
||||
.poll(() => page.evaluate(() =>
|
||||
(window as unknown as { __motion: { entered: string[] } }).__motion.entered,
|
||||
))
|
||||
.toContain("plmail-slide-down");
|
||||
|
||||
// And the rows below travelled to let it in.
|
||||
await expect
|
||||
.poll(() => page.evaluate(() =>
|
||||
(window as unknown as { __motion: { moved: number } }).__motion.moved,
|
||||
))
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// Nothing left behind: the inline transform and transition a FLIP needs
|
||||
// are cleared once it lands, so the next one measures a settled list.
|
||||
await expect
|
||||
.poll(() => page.evaluate(() =>
|
||||
Array.from(
|
||||
document.querySelectorAll('#message-list li[data-controller="mail--message-row"]'),
|
||||
).filter((row) => (row as HTMLElement).style.transform !== "").length,
|
||||
))
|
||||
.toBe(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* A row falling off the end.
|
||||
*
|
||||
* On a real page this is the fifty-first conversation after new mail
|
||||
* pushed it over, or a thread that no longer belongs in the open folder.
|
||||
* Either way the server simply does not send it, so the browser learns
|
||||
* about the departure by its absence — and the row has to be held back
|
||||
* long enough to be seen leaving, because a node cannot animate after it
|
||||
* has been removed.
|
||||
*/
|
||||
test("a row that falls off the end fades where it stood", async ({ page }) => {
|
||||
await page.goto("/mail/inbox");
|
||||
await expectSeededRows(page);
|
||||
|
||||
const doomed = await page.evaluate(
|
||||
() => document.querySelector('#message-list li[data-controller="mail--message-row"]')?.id,
|
||||
);
|
||||
expect(doomed).toBeTruthy();
|
||||
|
||||
await recordMotion(page);
|
||||
|
||||
// The same surgery as an arrival, in reverse: the refresh comes back
|
||||
// one row shorter than the list on screen.
|
||||
await page.route("**/mail/inbox**", async (route) => {
|
||||
if (route.request().headers()["x-list-fragment"] === undefined) {
|
||||
return route.continue();
|
||||
}
|
||||
|
||||
const response = await route.fetch();
|
||||
const body = await response.text();
|
||||
const opens = body.search(/<li[\s>]/);
|
||||
const closes = body.indexOf("</li>", opens) + "</li>".length;
|
||||
|
||||
return route.fulfill({
|
||||
response,
|
||||
body: body.slice(0, opens) + body.slice(closes),
|
||||
});
|
||||
});
|
||||
|
||||
await syncFired(page);
|
||||
|
||||
await expect
|
||||
.poll(() => page.evaluate(() =>
|
||||
(window as unknown as { __motion: { entered: string[] } }).__motion.entered,
|
||||
))
|
||||
.toContain("plmail-leave");
|
||||
|
||||
// And it is gone afterwards, not merely invisible — a row left behind
|
||||
// at opacity zero still takes clicks away from the row beneath it.
|
||||
await expect(page.locator(`#${doomed}`)).toHaveCount(0);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() =>
|
||||
document.querySelectorAll('#message-list li[data-leaving]').length,
|
||||
))
|
||||
.toBe(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* None means none, and it means it here too.
|
||||
*
|
||||
* The tier is the one place this whole feature can be turned off, so the
|
||||
* expensive path — measuring, inverting, holding a row back for its fade —
|
||||
* has to be genuinely skipped rather than merely run at zero duration.
|
||||
*/
|
||||
test("the arrival gesture is skipped entirely at the none tier", async ({ page }) => {
|
||||
await page.goto("/mail/inbox");
|
||||
await expectSeededRows(page);
|
||||
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.dataset.motion = "none";
|
||||
});
|
||||
|
||||
await recordMotion(page);
|
||||
await arrivingMail(page);
|
||||
await syncFired(page);
|
||||
|
||||
await expect(page.locator("#thread_99000001")).toHaveCount(1);
|
||||
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
(window as unknown as { __motion: { moved: number } }).__motion.moved,
|
||||
),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
// ── Still pending: needs more than wiring ────────────────────────────────
|
||||
|
||||
// Blocked: "Label as" never fires the POST from the UI. The only rendered
|
||||
|
||||
Reference in new issue
Block a user