Shipped (2026-05-29, commit 9da45d3a).
Pull-to-refresh is live on every page, gated by the (pointer: coarse) media query so desktop pointers are unaffected. New asset: /js/pull-to-refresh.js, loaded from layouts/base.php right after app.js.
UX: threshold 80px, max-pull 140px, animated rounded indicator pill at the top center, label flips to "Release to refresh" past threshold, 220ms intent feedback before location.reload(). Drag back up cancels.
Edge cases handled: starts a touch inside a scrolled inner container (e.g., the messages sidebar) is ignored; [data-no-ptr] on body or any ancestor opts out; bypasses entirely when window.scrollY > 0.
Implementation note: uses DOM APIs (createElementNS for the SVG arrow + textContent for the label) instead of innerHTML, keeping the script CSP-nonce-clean.