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"` : ""}> @@ -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; } }