contact messages: unread nudge animation instead of dot/pill badges

Unread state (new reply for visitors, unanswered threads for admins) now animates the trigger button and the corner icon themselves: a scale-up shake every 4.5 s, gated on prefers-reduced-motion, stopping when read (the has-unread class goes away). The static accent dot and the admin count pill are gone.
This commit is contained in:
LeoVasanko committed 2026-09-28 01:46:07 +00:00
1 parent afa6b967e6
commit d6088b90ff
4 files changed
+35 -73

No files matched your search

+7 -5
View File
@@ -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.
+21 -42
View File
@@ -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 {
+6 -20
View File
@@ -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) {
+1 -6
View File
@@ -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") || "";