eleven / messenger Public

web: smoother swipe-to-reply and a GPU-accelerated image viewer (#124)

Batch every gesture transform through requestAnimationFrame and drive it with translate3d on a promoted compositor layer, so swipe-to-reply and the lightbox stay smooth (notably in the iOS webview). The backdrop now dims via opacity on its own layer instead of repainting a colour each frame.

Add pinch-to-zoom with free two-finger rotation, double-tap zoom, pan-when-zoomed, and a FLIP open/close that flies the photo from and back into the thumbnail it was opened from. Tap-to-dismiss returns it into place; a flick still flings it off. A gesture that involved a pinch always settles rather than dismissing, so pinching back to 1x can't close the viewer by accident.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 2e2d493000f3dd8e29f63ac53613d87cebbf5db8 parent 0706d59
3 files changed, +380 −65
  • changelog/2026-07-03-smoother-gestures.md +1 −0
  • web/static/chat.js +366 −55
  • web/static/style.css +13 −10
diff --git a/changelog/2026-07-03-smoother-gestures.md b/changelog/2026-07-03-smoother-gestures.md
new file mode 100644
index 0000000..72a106e
--- /dev/null
+++ b/changelog/2026-07-03-smoother-gestures.md
@@ -0,0 +1 @@
+Smoother gestures: swipe-right-to-reply now glides without the little stutter it sometimes had, especially in the app. Tap a photo to open it and you can now pinch to zoom, double-tap to zoom in and out, drag to pan around, and flick it away to close.
diff --git a/web/static/chat.js b/web/static/chat.js
index af140a9..a4eb6ca 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -1567,7 +1567,7 @@ function paintHeaderAvatar(id) {
paintAvatar(el, threadName[id] || "?", loader).then((url) => {
if (url) {
el.classList.add("zoomable");
- el.onclick = () => openLightbox(url);
+ el.onclick = () => openLightbox(url, el);
}
});
}
@@ -1912,7 +1912,7 @@ async function appendMessage(threadId, m, scroll = true, container = null, cache
loadBlob(a.id, threadId).then((url) => {
if (url) {
img.src = url;
- img.onclick = () => openLightbox(url);
+ img.onclick = () => openLightbox(url, img);
if (scroll) feed.scrollTop = feed.scrollHeight;
}
});
@@ -2584,25 +2584,44 @@ function attachSwipeReply(el, meta) {
startY = 0,
dx = 0,
mode = "", // "" undecided, "swipe", or "no" (vertical/other)
- icon = null;
+ icon = null,
+ raf = 0;
+
+ // Write the transform once per frame (rAF-batched) using translate3d, which
+ // keeps the bubble on a GPU compositor layer — no per-move repaint of the
+ // subtree, so the drag stays smooth even in the iOS webview.
+ const render = () => {
+ raf = 0;
+ el.style.transform = "translate3d(" + dx + "px,0,0)";
+ if (icon) icon.style.opacity = Math.min(1, dx / TRIGGER);
+ };
+ const schedule = () => {
+ if (!raf) raf = requestAnimationFrame(render);
+ };
const cleanup = (animate) => {
+ if (raf) {
+ cancelAnimationFrame(raf);
+ raf = 0;
+ }
if (icon) {
icon.remove();
icon = null;
}
if (animate) {
el.style.transition = "transform 0.18s ease";
- el.style.transform = "translateX(0)";
+ el.style.transform = "translate3d(0,0,0)";
const done = () => {
el.style.transition = "";
el.style.transform = "";
+ el.style.willChange = "";
el.removeEventListener("transitionend", done);
};
el.addEventListener("transitionend", done);
} else {
el.style.transition = "";
el.style.transform = "";
+ el.style.willChange = "";
}
};
@@ -2621,6 +2640,7 @@ function attachSwipeReply(el, meta) {
if (Math.abs(ax) < 10 && Math.abs(ay) < 10) return;
mode = ax > 0 && Math.abs(ax) > Math.abs(ay) * 1.3 ? "swipe" : "no";
if (mode === "swipe") {
+ el.style.willChange = "transform"; // promote the bubble to its own layer for the drag
icon = document.createElement("div");
icon.className = "swipe-reply-icon";
icon.innerHTML = ICON_REPLY;
@@ -2629,8 +2649,7 @@ function attachSwipeReply(el, meta) {
}
if (mode !== "swipe") return;
dx = Math.max(0, Math.min(ax, MAX));
- el.style.transform = "translateX(" + dx + "px)";
- if (icon) icon.style.opacity = Math.min(1, dx / TRIGGER);
+ schedule();
});
const onEnd = (e) => {
if (e.pointerType && e.pointerType !== "touch") return;
@@ -3033,56 +3052,298 @@ function buildFileCard(a, threadId) {
return card;
}
-function openLightbox(url) {
+function openLightbox(url, source) {
+ const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
const back = document.createElement("div");
back.className = "lightbox";
+ // The dark backing is a separate layer so we can dim it by animating *opacity*
+ // (composited on the GPU, no repaint) instead of recomputing its color per move.
+ const backdrop = document.createElement("div");
+ backdrop.className = "lightbox-backdrop";
+ backdrop.style.opacity = "0"; // faded in by the open animation
const img = document.createElement("img");
img.src = url;
img.draggable = false;
+ img.style.visibility = "hidden"; // stays hidden until laid out, so the fly-in has no flash
+ back.appendChild(backdrop);
back.appendChild(img);
+
+ let tapTimer = 0; // pending single-tap-to-close, cancelled by a 2nd tap or a drag/pinch
+ let closing = false;
const close = () => {
+ clearTimeout(tapTimer);
back.remove();
document.removeEventListener("keydown", onKey);
};
const onKey = (e) => {
- if (e.key === "Escape") close();
+ if (e.key === "Escape") animateClose();
};
- // Tapping the backdrop (not the image) closes.
+ // Tapping empty space (not the photo) closes — unless a gesture just ran, whose
+ // synthesized click we swallow.
back.addEventListener("click", (e) => {
- if (e.target === back) close();
+ if (e.target === img) return;
+ if (suppressBackdropClick) {
+ suppressBackdropClick = false;
+ return;
+ }
+ animateClose();
});
document.addEventListener("keydown", onKey);
document.body.appendChild(back);
- // Flick-to-dismiss with momentum: drag the image and a fast flick (or a big drag)
- // flings it off in the release direction, carrying its velocity; otherwise it
- // springs back. A plain tap closes. Velocity is px/ms, sampled between moves.
- let dragging = false;
- let moved = false;
- let startX = 0;
- let startY = 0;
- let x = 0;
- let y = 0;
+ // --- gesture state -------------------------------------------------------
+ // One transform drives everything: translate3d + rotate + scale (GPU-composited).
+ // A single finger at 1× flick-dismisses; two fingers pinch-zoom-and-rotate about
+ // their midpoint; one finger while zoomed pans; double-tap toggles zoom.
+ const MIN_SCALE = 1;
+ const MAX_SCALE = 5;
+ const TAP_MS = 280; // double-tap window / lone-tap dismiss delay
+ let scale = 1;
+ let tx = 0;
+ let ty = 0;
+ let rot = 0; // rotation °: free during a pinch (springs upright), or a 1× drag tumble
+ let mode = ""; // "drag" (1× dismiss) | "pan" (zoomed) | "pinch"
+ let pinched = false; // did this gesture involve a pinch? if so it settles, never dismisses
+ let suppressBackdropClick = false;
+ let raf = 0;
+
+ // The image's at-rest centre in screen coords — the anchor for all zoom math and
+ // the fly-in/out target. Measured whenever we sit at the identity transform.
+ let baseCX = 0;
+ let baseCY = 0;
+ let haveBase = false;
+ const setBase = () => {
+ const r = img.getBoundingClientRect();
+ baseCX = r.left + r.width / 2;
+ baseCY = r.top + r.height / 2;
+ haveBase = true;
+ };
+ const ensureBase = () => {
+ if (scale === 1 && tx === 0 && ty === 0) setBase();
+ };
+
+ const pointers = new Map(); // pointerId -> {x, y}
+ // single-finger drag/pan tracking
+ let sx = 0;
+ let sy = 0;
+ let panStartTx = 0;
+ let panStartTy = 0;
let lastX = 0;
let lastY = 0;
let lastT = 0;
let vx = 0;
let vy = 0;
- img.addEventListener("pointerdown", (e) => {
- dragging = true;
- moved = false;
- startX = lastX = e.clientX;
- startY = lastY = e.clientY;
- lastT = e.timeStamp || performance.now();
- vx = vy = x = y = 0;
+ let moved = false;
+ // pinch anchors (captured when the second finger lands)
+ let pinchStartDist = 0;
+ let pinchStartScale = 1;
+ let pinchStartAngle = 0;
+ let pinchStartRot = 0;
+ let pinchVX = 0;
+ let pinchVY = 0;
+ // double-tap tracking
+ let lastTapT = 0;
+ let lastTapX = 0;
+ let lastTapY = 0;
+
+ const render = () => {
+ raf = 0;
+ let t = `translate3d(${tx}px, ${ty}px, 0)`;
+ if (rot) t += ` rotate(${rot}deg)`;
+ if (scale !== 1) t += ` scale(${scale})`;
+ img.style.transform = t;
+ // The backdrop only dims while dragging a 1× image toward dismissal.
+ backdrop.style.opacity =
+ mode === "drag" && !pinched ? String(Math.max(0, 1 - Math.hypot(tx, ty) / 700)) : "1";
+ };
+ const schedule = () => {
+ if (!raf) raf = requestAnimationFrame(render);
+ };
+
+ // Fly the photo up from the thumbnail it was opened from, then ease to rest
+ // (a FLIP / shared-element transition). Runs once the image has a size.
+ let openStarted = false;
+ const runOpenAnim = () => {
+ if (openStarted) return;
+ openStarted = true;
+ img.style.visibility = "";
+ setBase();
+ const S = source && source.getBoundingClientRect();
+ if (!haveBase || !img.clientWidth || !S || !S.width || reduceMotion) {
+ backdrop.style.transition = reduceMotion ? "" : "opacity 0.2s";
+ backdrop.style.opacity = "1"; // no thumbnail to fly from — just appear
+ return;
+ }
+ const s = Math.max(S.width / img.clientWidth, S.height / img.clientHeight);
+ const fromX = S.left + S.width / 2 - baseCX;
+ const fromY = S.top + S.height / 2 - baseCY;
img.style.transition = "none";
+ img.style.transform = `translate3d(${fromX}px, ${fromY}px, 0) scale(${s})`;
+ void img.offsetWidth; // reflow so the change below animates from here
+ img.style.transition = "transform 0.3s cubic-bezier(0.2,0.8,0.2,1)";
+ img.style.transform = "translate3d(0,0,0)";
+ backdrop.style.transition = "opacity 0.3s";
+ backdrop.style.opacity = "1";
+ };
+ if (img.complete && img.naturalWidth) runOpenAnim();
+ else {
+ img.addEventListener("load", runOpenAnim, { once: true });
+ img.addEventListener("error", runOpenAnim, { once: true });
+ }
+
+ // Reverse of the fly-in: shrink the photo back onto its thumbnail, then remove.
+ // Used by taps / Escape / backdrop closes; a fling instead hurls it off-screen.
+ const animateClose = () => {
+ if (closing) return;
+ closing = true;
+ clearTimeout(tapTimer);
+ if (raf) {
+ cancelAnimationFrame(raf);
+ raf = 0;
+ }
+ const S = source && source.getBoundingClientRect();
+ if (!haveBase || !S || !S.width || reduceMotion) return close();
+ const s = Math.max(S.width / img.clientWidth, S.height / img.clientHeight);
+ const toX = S.left + S.width / 2 - baseCX;
+ const toY = S.top + S.height / 2 - baseCY;
+ img.style.transition = "transform 0.28s cubic-bezier(0.4,0,0.2,1)";
+ img.style.transform = `translate3d(${toX}px, ${toY}px, 0) scale(${s})`;
+ backdrop.style.transition = "opacity 0.28s";
+ backdrop.style.opacity = "0";
+ setTimeout(close, 300);
+ };
+
+ // Keep the transformed image within reach of the viewport when zoomed.
+ const clampPan = () => {
+ const maxX = Math.max(0, (img.clientWidth * scale - window.innerWidth) / 2 + 20);
+ const maxY = Math.max(0, (img.clientHeight * scale - window.innerHeight) / 2 + 20);
+ tx = Math.max(-maxX, Math.min(maxX, tx));
+ ty = Math.max(-maxY, Math.min(maxY, ty));
+ };
+
+ // Animate to a target scale anchored at a screen point (double-tap). Zooming
+ // out always recentres.
+ const zoomTo = (target, cx, cy) => {
+ if (raf) {
+ cancelAnimationFrame(raf);
+ raf = 0;
+ }
+ if (target <= MIN_SCALE) {
+ scale = 1;
+ tx = 0;
+ ty = 0;
+ } else {
+ const vX = (cx - baseCX - tx) / scale;
+ const vY = (cy - baseCY - ty) / scale;
+ scale = Math.min(MAX_SCALE, target);
+ tx = cx - baseCX - scale * vX;
+ ty = cy - baseCY - scale * vY;
+ clampPan();
+ }
+ rot = 0;
+ img.style.transition = "transform 0.25s cubic-bezier(0.2,0.8,0.2,1)";
+ img.style.transform = scale === 1 ? "translate3d(0,0,0)" : `translate3d(${tx}px, ${ty}px, 0) scale(${scale})`;
+ backdrop.style.transition = "opacity 0.25s";
+ backdrop.style.opacity = "1";
+ };
+
+ // Ease back to the identity transform (1×, centred, upright).
+ const springBack = () => {
+ scale = 1;
+ tx = 0;
+ ty = 0;
+ rot = 0;
+ img.style.transition = "transform 0.28s cubic-bezier(0.2,0.9,0.2,1)";
+ img.style.transform = "translate3d(0,0,0)";
+ backdrop.style.transition = "opacity 0.28s";
+ backdrop.style.opacity = "1";
+ };
+
+ // Settle a still-zoomed image: keep the zoom, clamp into view, straighten any
+ // rotation the pinch left behind.
+ const settleZoom = () => {
+ clampPan();
+ rot = 0;
+ img.style.transition = "transform 0.28s cubic-bezier(0.2,0.9,0.2,1)";
+ img.style.transform = `translate3d(${tx}px, ${ty}px, 0) scale(${scale})`;
+ backdrop.style.transition = "opacity 0.28s";
+ backdrop.style.opacity = "1";
+ };
+
+ img.addEventListener("pointerdown", (e) => {
img.setPointerCapture?.(e.pointerId);
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
+ clearTimeout(tapTimer); // a new touch means this isn't a tap-and-wait dismiss
+ if (raf) {
+ cancelAnimationFrame(raf);
+ raf = 0;
+ }
+ img.style.transition = "none";
+ backdrop.style.transition = "none";
+ ensureBase();
+ if (pointers.size >= 2) {
+ // Second finger down: start a pinch, anchoring the current midpoint + angle.
+ const [a, b] = [...pointers.values()];
+ pinchStartDist = Math.hypot(a.x - b.x, a.y - b.y) || 1;
+ pinchStartScale = scale;
+ pinchStartAngle = Math.atan2(b.y - a.y, b.x - a.x);
+ pinchStartRot = rot;
+ const midX = (a.x + b.x) / 2;
+ const midY = (a.y + b.y) / 2;
+ pinchVX = (midX - baseCX - tx) / scale;
+ pinchVY = (midY - baseCY - ty) / scale;
+ mode = "pinch";
+ moved = true;
+ pinched = true;
+ suppressBackdropClick = true;
+ } else {
+ // First finger of a fresh gesture: pan if zoomed, else a candidate dismiss drag.
+ pinched = false;
+ sx = e.clientX;
+ sy = e.clientY;
+ panStartTx = tx;
+ panStartTy = ty;
+ lastX = e.clientX;
+ lastY = e.clientY;
+ lastT = e.timeStamp || performance.now();
+ vx = vy = 0;
+ moved = false;
+ rot = 0;
+ mode = scale > 1 ? "pan" : "drag";
+ }
});
+
img.addEventListener("pointermove", (e) => {
- if (!dragging) return;
- x = e.clientX - startX;
- y = e.clientY - startY;
- if (Math.abs(x) > 6 || Math.abs(y) > 6) moved = true;
+ const p = pointers.get(e.pointerId);
+ if (!p) return;
+ p.x = e.clientX;
+ p.y = e.clientY;
+ if (mode === "pinch") {
+ const pts = [...pointers.values()];
+ if (pts.length < 2) return;
+ const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) || 1;
+ const midX = (pts[0].x + pts[1].x) / 2;
+ const midY = (pts[0].y + pts[1].y) / 2;
+ scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, (pinchStartScale * dist) / pinchStartDist));
+ const ang = Math.atan2(pts[1].y - pts[0].y, pts[1].x - pts[0].x);
+ rot = pinchStartRot + (ang - pinchStartAngle) * (180 / Math.PI); // free twist
+ tx = midX - baseCX - scale * pinchVX;
+ ty = midY - baseCY - scale * pinchVY;
+ clampPan();
+ schedule();
+ return;
+ }
+ if (mode === "pan") {
+ tx = panStartTx + (e.clientX - sx);
+ ty = panStartTy + (e.clientY - sy);
+ clampPan();
+ schedule();
+ return;
+ }
+ // mode === "drag": 1× dismiss drag with velocity sampling (px/ms).
+ tx = e.clientX - sx;
+ ty = e.clientY - sy;
+ if (Math.abs(tx) > 6 || Math.abs(ty) > 6) moved = true;
const now = e.timeStamp || performance.now();
const dt = now - lastT;
if (dt > 0) {
@@ -3092,22 +3353,45 @@ function openLightbox(url) {
lastX = e.clientX;
lastY = e.clientY;
lastT = now;
- // Fade the backdrop as the image is dragged away.
- back.style.background = `rgba(0,0,0,${Math.max(0, 0.9 - Math.hypot(x, y) / 700)})`;
- img.style.transform = `translate(${x}px, ${y}px) rotate(${x / 40}deg)`;
+ rot = tx / 40;
+ schedule();
});
- const release = (e) => {
- if (!dragging) return;
- dragging = false;
+
+ const armSuppress = () => {
+ suppressBackdropClick = true;
+ setTimeout(() => (suppressBackdropClick = false), 350);
+ };
+
+ const flingOrSettle = (e) => {
+ // A tap on the photo. A second tap within the window is a double-tap (toggle
+ // zoom); otherwise a lone tap dismisses at 1× — but only after the window
+ // elapses, so it can't fire before a double-tap is ruled out.
+ if (!moved) {
+ const now = e.timeStamp || performance.now();
+ if (now - lastTapT < TAP_MS && Math.hypot(e.clientX - lastTapX, e.clientY - lastTapY) < 30) {
+ lastTapT = 0;
+ zoomTo(scale > 1 ? MIN_SCALE : 2.5, e.clientX, e.clientY);
+ armSuppress();
+ return;
+ }
+ lastTapT = now;
+ lastTapX = e.clientX;
+ lastTapY = e.clientY;
+ if (scale === 1) tapTimer = setTimeout(animateClose, TAP_MS); // lone tap → dismiss into place
+ return;
+ }
+ // Anything that involved a pinch, or ended zoomed, settles — it never dismisses,
+ // so pinching back down to 1× can't fling the photo away by accident.
+ if (pinched || mode === "pan" || scale > 1) {
+ if (scale <= 1.001) springBack();
+ else settleZoom();
+ armSuppress();
+ return;
+ }
+ // Clean 1× drag: spring back, or fling off with momentum.
const speed = Math.hypot(vx, vy); // px/ms
- const dist = Math.hypot(x, y);
- if (speed < 0.55 && dist < 130) {
- if (!moved) return close(); // a tap
- // Not enough — spring back.
- img.style.transition = "transform 0.28s cubic-bezier(0.2,0.9,0.2,1)";
- img.style.transform = "translate(0,0)";
- back.style.transition = "background 0.28s";
- back.style.background = "";
+ if (speed < 0.55 && Math.hypot(tx, ty) < 130) {
+ springBack();
return;
}
// Fling: continue in the release direction (fall back to the drag direction if it
@@ -3115,23 +3399,50 @@ function openLightbox(url) {
let dx = vx;
let dy = vy;
if (speed < 0.05) {
- dx = x;
- dy = y;
+ dx = tx;
+ dy = ty;
}
const norm = Math.hypot(dx, dy) || 1;
const reach = Math.max(window.innerWidth, window.innerHeight) * 1.25;
- const tx = x + (dx / norm) * reach;
- const ty = y + (dy / norm) * reach;
- const spin = Math.max(-65, Math.min(65, tx / 20)); // tumbles more as it leaves
+ const fx = tx + (dx / norm) * reach;
+ const fy = ty + (dy / norm) * reach;
+ const spin = Math.max(-65, Math.min(65, fx / 20)); // tumbles more as it leaves
+ closing = true;
img.style.transition = "transform 0.5s cubic-bezier(0.25,0.8,0.35,1)";
- img.style.transform = `translate(${tx}px, ${ty}px) rotate(${spin}deg)`;
- back.style.transition = "opacity 0.45s, background 0.45s";
- back.style.background = "rgba(0,0,0,0)";
- back.style.opacity = "0";
+ img.style.transform = `translate3d(${fx}px, ${fy}px, 0) rotate(${spin}deg)`;
+ backdrop.style.transition = "opacity 0.45s";
+ backdrop.style.opacity = "0";
setTimeout(close, 480);
};
- img.addEventListener("pointerup", release);
- img.addEventListener("pointercancel", release);
+
+ const onUp = (e) => {
+ if (!pointers.has(e.pointerId)) return;
+ pointers.delete(e.pointerId);
+ img.releasePointerCapture?.(e.pointerId);
+ if (raf) {
+ cancelAnimationFrame(raf);
+ raf = 0;
+ }
+ if (pointers.size === 1) {
+ // Dropped from a pinch to one finger — hand off to pan/drag without a jump.
+ const [rp] = [...pointers.values()];
+ sx = rp.x;
+ sy = rp.y;
+ panStartTx = tx;
+ panStartTy = ty;
+ lastX = rp.x;
+ lastY = rp.y;
+ lastT = e.timeStamp || performance.now();
+ vx = vy = 0;
+ moved = true; // came from a pinch; not a tap
+ mode = scale > 1 ? "pan" : "drag";
+ return;
+ }
+ if (pointers.size >= 1) return;
+ flingOrSettle(e);
+ };
+ img.addEventListener("pointerup", onUp);
+ img.addEventListener("pointercancel", onUp);
}
// ---- websocket ----------------------------------------------------------
diff --git a/web/static/style.css b/web/static/style.css
index dce4923..d1c4dee 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -447,29 +447,34 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
position: fixed;
inset: 0;
z-index: 80;
- background: rgba(0, 0, 0, 0.9);
+ background: transparent; /* the dark backing is its own layer (.lightbox-backdrop) */
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
+/* separate layer so dimming animates via opacity (GPU-composited) — no repaint */
+.lightbox-backdrop {
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+ background: rgba(0, 0, 0, 0.9);
+ will-change: opacity;
+}
.lightbox img {
+ position: relative;
+ z-index: 1; /* sit above the backdrop */
max-width: 100%;
max-height: 100%;
border-radius: 8px;
- touch-action: none; /* we own the drag: flick-to-dismiss, no browser pan/zoom */
+ touch-action: none; /* we own the gesture: pinch-zoom, pan, flick-to-dismiss */
user-select: none;
-webkit-user-drag: none;
will-change: transform;
}
-/* open animation: the backdrop fades, the image rises into place */
-.lightbox { animation: fade-in 0.18s ease; }
-.lightbox img { animation: lightbox-img-in 0.26s cubic-bezier(0.2, 0.8, 0.2, 1); }
+/* the lightbox open/close is driven in JS (a FLIP fly-in/out from the thumbnail) */
@keyframes fade-in { from { opacity: 0; } }
-@keyframes lightbox-img-in {
- from { opacity: 0; transform: translateY(14px) scale(0.98); }
-}
/* a newly-sent/arrived message eases up into the feed (initial history doesn't) */
.msg.appear { animation: msg-appear 0.22s cubic-bezier(0.2, 0.8, 0.2, 1); }
@@ -478,8 +483,6 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
}
@media (prefers-reduced-motion: reduce) {
- .lightbox,
- .lightbox img,
.msg.appear { animation: none; }
}