diff --git a/package.json b/package.json
index cc8f4c1..3b28ecc 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "catchbox",
- "version": "1.4.1",
+ "version": "1.5.0",
"description": "A disposable inbox for developers — grab an address, catch the mail, pull out the link or the code.",
"type": "module",
"bin": {
diff --git a/src/ui/app.js b/src/ui/app.js
index 6f2122b..3f0f61f 100644
--- a/src/ui/app.js
+++ b/src/ui/app.js
@@ -28,6 +28,35 @@ let pendingDelete = null; // { items, timer }
// Set by the macOS app before this script runs. It posts its own notifications, natively.
const inApp = Boolean(window.catchboxApp);
+/* --- motion --------------------------------------------------------------- */
+// The list is rebuilt from scratch on every render, so a plain CSS animation would start
+// over each time. Instead every animated row remembers when it started, and a rebuild
+// hands it a negative delay that resumes it where it was.
+const motion = {
+ known: null, // ids in the mailbox at the last refresh; null = show it entering
+ entrance: 0, // when the current mailbox came into view
+ arrivals: new Map(), // id -> when it arrived
+ ticked: null, // { id, at } — the tick that just landed
+ unread: new Map(), // mailbox id -> unread count last drawn, to bump when it rises
+};
+const reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
+
+// Rows slide out and fold shut before the list closes over them.
+const leave = (ids) => {
+ const rows = [...el.list.querySelectorAll("li[data-id]")].filter((li) => ids.has(li.dataset.id));
+ if (!rows.length || reducedMotion() || !Element.prototype.animate) return Promise.resolve();
+ return Promise.all(rows.map((li) => {
+ const { paddingTop, paddingBottom } = getComputedStyle(li);
+ const h = `${li.offsetHeight}px`;
+ li.style.overflow = "hidden";
+ return li.animate([
+ { opacity: 1, transform: "none", height: h, paddingTop, paddingBottom },
+ { opacity: 0, transform: "translateX(-28px)", height: h, paddingTop, paddingBottom, offset: 0.45 },
+ { opacity: 0, transform: "translateX(-28px)", height: "0px", paddingTop: "0px", paddingBottom: "0px" },
+ ], { duration: 340, easing: "cubic-bezier(.2, .8, .2, 1)", fill: "forwards" }).finished.catch(() => {});
+ }));
+};
+
/* --- helpers -------------------------------------------------------------- */
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) =>
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
@@ -230,6 +259,13 @@ const notify = (m, acc) => {
};
/* --- mailbox rail --------------------------------------------------------- */
+// " bump" when a count went up since it was last drawn — once, not on every redraw.
+const bumped = (id, n) => {
+ const before = motion.unread.get(id);
+ motion.unread.set(id, n);
+ return before !== undefined && n > before ? " bump" : "";
+};
+
const renderRail = () => {
const many = accounts.length > 1;
const rows = accounts.map((a, i) => `
@@ -240,7 +276,7 @@ const renderRail = () => {
${esc(a.address)}
${a.id === serverCurrent && many ? `CLI` : ""}
- ${a.unread ? `${a.unread}` : ""}
+ ${a.unread ? `${a.unread}` : ""}
`).join("");
const total = accounts.reduce((n, a) => n + a.unread, 0);
@@ -248,7 +284,7 @@ const renderRail = () => {
` : "";
el.rail.innerHTML = `
@@ -276,6 +312,7 @@ const switchBox = async (next) => {
box = next;
currentId = null;
picked.clear();
+ motion.known = null;
renderRail();
try {
await refresh();
@@ -363,7 +400,21 @@ const renderList = () => {
return;
}
- el.list.innerHTML = visible.map((m) => {
+ const now = performance.now();
+ const sinceEntrance = now - motion.entrance;
+ const animation = (m, i) => {
+ const arrived = motion.arrivals.get(m.id);
+ if (arrived !== undefined) {
+ if (now - arrived < 2400) return ["arrive", now - arrived];
+ motion.arrivals.delete(m.id);
+ }
+ // Only the first screenful staggers in; further down nobody sees it, and waiting on it would feel slow.
+ if (i < 14 && sinceEntrance < i * 28 + 400) return ["enter", sinceEntrance - i * 28];
+ return null;
+ };
+ const tickedNow = motion.ticked && now - motion.ticked.at < 300 ? motion.ticked.id : null;
+
+ el.list.innerHTML = visible.map((m, i) => {
const acc = accounts.find((a) => a.id === m.account);
const chips = [
m.code ? `${esc(m.code)}` : "",
@@ -374,8 +425,11 @@ const renderList = () => {
const preview = snippet(m);
const isPicked = picked.has(m.id);
+ const anim = animation(m, i);
+ const cls = [m.seen ? "" : "unread", isPicked ? "picked" : "",
+ isPicked && m.id === tickedNow ? "ticked" : "", anim?.[0] ?? ""].filter(Boolean).join(" ");
return `
+ aria-selected="${m.id === currentId}" class="${cls}"${anim ? ` style="--d:${Math.round(-anim[1])}ms"` : ""}>
${esc(senderInitial(m))}
${icon.check}
@@ -486,7 +540,7 @@ const renderDetail = async (m) => {
Verification code
@@ -632,6 +686,7 @@ const bulk = (action, items) => api("/api/messages", {
const togglePick = (id) => {
if (picked.has(id)) picked.delete(id); else picked.add(id);
anchor = id;
+ motion.ticked = { id, at: performance.now() };
renderList();
};
@@ -664,12 +719,16 @@ const prunePicked = () => {
// mail.tm has no bin to restore from, so the undo lives here: the messages leave the list
// at once, and the delete itself is only sent when the undo window closes.
-const removeMessages = (list) => {
+const removeMessages = async (list) => {
if (!list.length) return;
if (demoMode) return toast("Demo mode — nothing is deleted here");
flushDelete(); // a second delete commits the first, so there is only ever one to undo
- for (const m of list) { hiddenIds.add(m.id); picked.delete(m.id); }
+ // Hidden at once, so a refresh during the animation cannot bring them back; and
+ // forgotten, so an undo brings them back in with the arrival animation.
+ const ids = new Set(list.map((m) => m.id));
+ for (const id of ids) { hiddenIds.add(id); picked.delete(id); motion.known?.delete(id); }
+ await leave(ids);
messages = messages.filter((m) => !hiddenIds.has(m.id));
if (currentId && hiddenIds.has(currentId)) currentId = null;
renderList();
@@ -877,8 +936,18 @@ const renderSettings = () => {
`;
};
-const openSettings = () => { el.settings.hidden = false; renderSettings(); };
-const closeSettings = () => { el.settings.hidden = true; };
+const openSettings = () => { el.settings.classList.remove("closing"); el.settings.hidden = false; renderSettings(); };
+const closeSettings = () => {
+ if (el.settings.hidden || el.settings.classList.contains("closing")) return;
+ const done = () => {
+ if (!el.settings.classList.contains("closing")) return; // reopened in the meantime
+ el.settings.hidden = true;
+ el.settings.classList.remove("closing");
+ };
+ if (reducedMotion()) return done();
+ el.settings.classList.add("closing");
+ setTimeout(done, 160);
+};
// A click on the dimmed backdrop closes Settings — but only when it also started there.
// Pressing inside the card and releasing outside it (say, drag-selecting a name) reports
@@ -1006,6 +1075,11 @@ const refresh = async () => {
el.addrText.textContent = address || `${accounts.length} inboxes, newest first`;
el.addr.disabled = !address;
messages = data.messages.filter((m) => !hiddenIds.has(m.id));
+ // First load of a mailbox: the rows stagger in. After that, anything not seen before arrives.
+ const now = performance.now();
+ if (!motion.known) motion.entrance = now;
+ else for (const m of messages) if (!motion.known.has(m.id)) motion.arrivals.set(m.id, now);
+ motion.known = new Set(messages.map((m) => m.id));
prunePicked();
renderList();
if (!currentId) emptyDetail();
@@ -1065,7 +1139,7 @@ el.detail.onclick = (e) => {
copy(c.dataset.copy, c.dataset.what || "Copied").then((ok) => {
if (!ok || !otp) return;
otp.classList.add("flash");
- setTimeout(() => otp.classList.remove("flash"), 700);
+ setTimeout(() => otp.classList.remove("flash"), 900);
});
return;
}
diff --git a/src/ui/style.css b/src/ui/style.css
index 27c65d7..d602f93 100644
--- a/src/ui/style.css
+++ b/src/ui/style.css
@@ -736,7 +736,7 @@ kbd {
transition: box-shadow .15s, color .15s, transform .15s;
}
.otp:hover span { box-shadow: 0 0 0 1.5px var(--accent); color: var(--accent); }
-.otp.flash span { transform: translateY(-2px); box-shadow: 0 0 0 2px var(--ok); color: var(--ok); }
+.otp.flash span { box-shadow: 0 0 0 2px var(--ok); color: var(--ok); }
.otp.long span { width: auto; padding: 0 14px; letter-spacing: .12em; }
.link-host { display: block; font-size: 15px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -1244,6 +1244,68 @@ table.headers td:last-child { overflow-wrap: anywhere; }
}
#toast button:hover { background: rgba(255, 255, 255, .12); }
+/* --- motion --------------------------------------------------------------- */
+/* Rows are rebuilt on every render, so their animations carry --d: how far along they
+ already were (as a negative delay), and a rebuild resumes them instead of restarting. */
+:root {
+ --ease-out: cubic-bezier(.2, .8, .2, 1);
+ --spring: cubic-bezier(.34, 1.56, .64, 1);
+}
+
+/* Switching mailbox: the rows come in one after the other. */
+.list li.enter { animation: rise-in .38s var(--ease-out) var(--d, 0ms) both; }
+@keyframes rise-in { from { opacity: 0; transform: translateY(8px); } }
+
+/* New mail: drops in from the top and glows once in the accent. */
+.list li.arrive {
+ animation:
+ arrive .6s var(--spring) var(--d, 0ms) both,
+ glow 2.2s ease-out var(--d, 0ms);
+}
+@keyframes arrive { from { opacity: 0; transform: translateY(-16px) scale(.96); } }
+@keyframes glow {
+ from { background: color-mix(in srgb, var(--accent) 18%, transparent); }
+}
+.list li.arrive .avatar { animation: pop .5s var(--spring) var(--d, 0ms) both; }
+@keyframes pop { from { transform: scale(.4); opacity: 0; } }
+
+/* A tick lands with a little bounce. */
+.list li.ticked .tick svg { animation: pop .3s var(--spring); }
+
+/* An unread count that went up. */
+.badge.bump { display: inline-block; animation: bump .45s var(--spring); }
+@keyframes bump { 40% { transform: scale(1.45); color: var(--accent); } }
+
+/* Opening a message: its parts settle in from top to bottom. */
+.letter > * { animation: rise-in .32s var(--ease-out) both; }
+.letter > :nth-child(2) { animation-delay: 30ms; }
+.letter > :nth-child(3) { animation-delay: 60ms; }
+.letter > :nth-child(4) { animation-delay: 90ms; }
+.letter > :nth-child(n+5) { animation-delay: 120ms; }
+
+/* The code, one digit at a time — and a wave across it when copied. Both stay in the
+ list on .flash, so removing .flash does not replay the entrance. */
+.otp span { animation: digit .45s var(--spring) calc(var(--i, 0) * 45ms + 140ms) both; }
+.otp.flash span {
+ animation:
+ digit .45s var(--spring) calc(var(--i, 0) * 45ms + 140ms) both,
+ hop .5s var(--spring) calc(var(--i, 0) * 35ms);
+}
+@keyframes digit { from { opacity: 0; transform: translateY(10px) scale(.8); } }
+@keyframes hop { 35% { transform: translateY(-7px); } }
+
+/* Waiting for mail: the envelope floats while the ripples go out. */
+.pulse:not(.still) svg { animation: float 3s ease-in-out infinite; }
+@keyframes float { 50% { transform: translateY(-3px) rotate(-4deg); } }
+
+/* Theme switch: the new icon spins in. */
+#theme svg { animation: spin-in .45s var(--spring); }
+@keyframes spin-in { from { transform: rotate(-90deg) scale(.5); opacity: 0; } }
+
+/* Settings leave the way they came. */
+.settings.closing { animation: fade .16s ease-in reverse both; }
+.settings.closing .settings-inner { animation: rise .16s ease-in reverse both; }
+
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}