diff --git a/docs/messages.md b/docs/messages.md index 0a992c2..5455d24 100644 --- a/docs/messages.md +++ b/docs/messages.md @@ -44,10 +44,12 @@ The token is `crypto.randomUUID()` in localStorage its blake3 hash as the thread key. Pages are shared/cached renders, so the thread UI is entirely client-side. A per-browser "seen" timestamp (`pagerite-contact-seen`) and the newest admin reply's timestamp -(`pagerite-contact-lastadmin`) drive the unread dot on trigger buttons and -the corner ✉️ icon when an admin reply arrives while the panel is closed — -including on a fresh page load before any socket activity (pagerite.js -reads the pair directly; the bundle keeps it live once loaded). +(`pagerite-contact-lastadmin`) drive the unread marker (a periodic +scale-and-shake nudge animation, `has-unread` in pagerite.css) on trigger +buttons and the corner ✉️ icon when an admin reply arrives while the panel +is closed — including on a fresh page load before any socket activity +(pagerite.js reads the pair directly; the bundle keeps it live once +loaded). The panel rides the public `/_ws` activity socket via a bridge in `pagerite.js` (`register`/`send`, sharing its queue and reconnect pacing — @@ -101,7 +103,7 @@ Replies store the SSO `remote-user` name for audit but are labeled with the currently configured **site name** in the wire payload (substituted at read time) and mark the thread read; `unread` on a thread means its last message is the visitor's and newer than the admin's last read. The icon's -badge (a count of unanswered threads) is seeded by the `/_api/settings` +unread marker (any unanswered thread) is seeded by the `/_api/settings` probe's `contact_unread` on every page load and kept live by the inbox while it is open. diff --git a/frontend/src/assets/pagerite.css b/frontend/src/assets/pagerite.css index 2fdad55..e3694c5 100644 --- a/frontend/src/assets/pagerite.css +++ b/frontend/src/assets/pagerite.css @@ -408,10 +408,9 @@ body.editing #sidebar { } /* The corner ✉️ (docs/messages.md) is in the pens row for everyone, so - needs its own positioning context for the unread markers: the same - accent dot as the trigger buttons (visitors: a new reply) or a count - pill (admins: unanswered threads). The marker lifts the icon out of the - icon-btn dimming so it reads clearly. */ + needs its own positioning context. Unread (a new reply for visitors, + unanswered threads for admins) lifts it out of the icon-btn dimming + and animates it (see contact-nudge by the trigger button). */ .contact-icon { position: relative; } @@ -420,34 +419,6 @@ body.editing #sidebar { opacity: 1; } -.contact-icon.has-unread::after { - content: ""; - position: absolute; - top: -0.2em; - right: -0.25em; - width: 0.5em; - height: 0.5em; - border-radius: 50%; - background: var(--accent); - box-shadow: 0 0 0 0.12em var(--bg); -} - -.contact-icon .n { - position: absolute; - top: -0.5em; - right: -0.7em; - min-width: 1.3em; - padding: 0.05em 0.3em; - border-radius: 1em; - background: var(--accent); - color: var(--bg); - font-size: 0.62em; - font-weight: 700; - line-height: 1.3; - text-align: center; - opacity: 1; -} - #sidebar { grid-column: 1; /* Pinned to the page's start edge (not the article's) and kept in view @@ -948,16 +919,24 @@ article { outline-offset: 2px; } -.contact-trigger.has-unread::after { - content: ""; - position: absolute; - top: -0.25em; - right: -0.25em; - width: 0.6em; - height: 0.6em; - border-radius: 50%; - background: var(--accent); - box-shadow: 0 0 0 0.15em var(--bg); +/* Unread (a new admin reply, or unanswered threads on the corner icon — + the .has-unread class, set by contact-main): a scale-up with a shake + every few seconds to draw the eye, stopping when read (the class goes + away). No static badge; reduced-motion users just get the lift above. */ +@media (prefers-reduced-motion: no-preference) { + .contact-trigger.has-unread, + .contact-icon.has-unread { + animation: contact-nudge 4.5s ease-in-out infinite; + } + + @keyframes contact-nudge { + 0%, 16%, 100% { transform: none; } + 2% { transform: scale(1.15) rotate(-8deg); } + 5% { transform: scale(1.15) rotate(7deg); } + 8% { transform: scale(1.15) rotate(-5deg); } + 11% { transform: scale(1.15) rotate(4deg); } + 14% { transform: scale(1.12); } + } } .edit-link { diff --git a/frontend/src/contact-main.js b/frontend/src/contact-main.js index d0ad406..6f8517f 100644 --- a/frontend/src/contact-main.js +++ b/frontend/src/contact-main.js @@ -105,32 +105,18 @@ async function onPow(msg) { } } -// Unread markers: a dot on every contact trigger button while the panel -// is closed and the newest admin message is younger than the last look, -// and on the corner ✉️ icon — a count of unanswered threads for admins, -// the same dot for visitors. Called on thread pushes and (from -// pagerite.js) after navigations, whose swap rebuilds the buttons. +// Unread markers: the has-unread class (a periodic nudge animation, see +// pagerite.css) on every contact trigger button and on the corner ✉️ icon +// — a new admin reply for visitors, any unanswered thread for admins. +// Called on thread pushes and (from pagerite.js) after navigations, whose +// swap rebuilds the buttons. export function refreshBadges() { for (const btn of document.querySelectorAll('.contact-trigger')) { btn.classList.toggle('has-unread', state.unread) } const icon = document.getElementById('contact-icon') if (!icon) return - if (state.viewerAdmin) { - let badge = icon.querySelector('.n') - if (state.adminUnread > 0) { - if (!badge) { - badge = document.createElement('span') - badge.className = 'n' - icon.append(badge) - } - badge.textContent = state.adminUnread - } else { - badge?.remove() - } - } else { - icon.classList.toggle('has-unread', state.unread) - } + icon.classList.toggle('has-unread', state.viewerAdmin ? state.adminUnread > 0 : state.unread) } function onThread(thread) { diff --git a/frontend/src/pagerite.js b/frontend/src/pagerite.js index be711b5..7158ab1 100644 --- a/frontend/src/pagerite.js +++ b/frontend/src/pagerite.js @@ -199,12 +199,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; b.title = admin ? "messages" : "contact us"; b.textContent = "✉️"; if (admin) { - if (adminUnreadSeed > 0) { - const badge = document.createElement("span"); - badge.className = "n"; - badge.textContent = adminUnreadSeed; - b.append(badge); - } + if (adminUnreadSeed > 0) b.classList.add("has-unread"); } else { const seen = Number(localStorage.getItem("pagerite-contact-seen") || 0); const last = localStorage.getItem("pagerite-contact-lastadmin") || "";