Pause AlertToast auto-dismiss while hovered (#235)

Each alert toast had a 5-second auto-dismiss timer that fired even
while the user was reading the card. This adds pause-on-hover: the
dismiss timer stops while the mouse is over a toast and restarts (full
lifetime) on mouse leave. The progress bar animation pauses with it,
so the visual matches the actual remaining time.

All other behavior is preserved: same cyber/mono styling, same spring
slide-in, same risk-color border + glow, same warning icon, same
LVL X/10 readout, same title/source layout, same click-to-fly + dismiss
on body click, same × dismiss button.

Implementation notes:
- Extract a ToastCard sub-component so each card can own its own
  paused state (useState can't be array-indexed in the parent).
- Move the auto-dismiss timer out of useAlertToasts.ts and into
  ToastCard. The hook previously scheduled the dismiss itself, which
  meant the UI couldn't pause it — only the component knows whether
  the user is interacting.
- Add tests covering: title/source/severity render, auto-dismiss
  fires at 5s, hover pauses indefinitely, mouse-leave restarts the
  full lifetime, × dismisses without flying, body-click flies +
  dismisses.

This implements the genuine UX improvement that PR #234 was reaching
for, without #234's broken syntax, missing-field bug, duplicate
timer logic, or design regression.

Refs: #234

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Shadowbroker
2026-05-19 00:49:36 -06:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 40734e310b
commit 421682c447
3 changed files with 261 additions and 113 deletions
+127 -81
View File
@@ -1,8 +1,11 @@
'use client';
import { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import type { ToastItem } from '@/hooks/useAlertToasts';
const TOAST_LIFETIME_MS = 5_000;
function getRiskColor(score: number): string {
if (score >= 9) return '#ef4444';
if (score >= 7) return '#f97316';
@@ -16,6 +19,122 @@ function getRiskLabel(score: number): string {
return 'ELEVATED';
}
/**
* ToastCard — renders a single toast with hover-to-pause auto-dismiss.
*
* Each card owns its own 5s dismiss timer. Hovering the card pauses the
* timer; the timer restarts (full duration) on mouse leave. All visual
* styling, the progress bar animation, the click-to-fly behavior, and
* the dismiss button match the previous inline implementation — the
* only behavioral change is the pause-on-hover.
*/
function ToastCard({
toast,
onDismiss,
onFlyTo,
}: {
toast: ToastItem;
onDismiss: (id: string) => void;
onFlyTo?: (lat: number, lng: number) => void;
}) {
const [isPaused, setIsPaused] = useState(false);
const color = getRiskColor(toast.risk_score);
const label = getRiskLabel(toast.risk_score);
// Per-toast auto-dismiss timer. Restarts whenever the pause flag flips
// off — so hovering resets the clock back to a full lifetime when the
// user moves the mouse away, giving them time to actually read it.
useEffect(() => {
if (isPaused) return;
const timer = setTimeout(() => {
onDismiss(toast.id);
}, TOAST_LIFETIME_MS);
return () => clearTimeout(timer);
}, [isPaused, toast.id, onDismiss]);
return (
<motion.div
key={toast.id}
layout
initial={{ opacity: 0, x: 100, scale: 0.9 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 100, scale: 0.9 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="pointer-events-auto cursor-pointer"
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
onClick={() => {
if (onFlyTo && toast.lat && toast.lng) {
onFlyTo(toast.lat, toast.lng);
}
onDismiss(toast.id);
}}
>
<div
className="relative bg-[rgba(5,5,5,0.96)] backdrop-blur-sm rounded-sm overflow-hidden font-mono"
style={{
borderLeft: `3px solid ${color}`,
boxShadow: `0 0 20px ${color}40, 0 4px 12px rgba(0,0,0,0.5)`,
}}
>
{/* Progress bar — animation pauses while the card is hovered. */}
<motion.div
className="absolute top-0 left-0 h-[2px]"
style={{ background: color }}
initial={{ width: '100%' }}
animate={{ width: isPaused ? '100%' : '0%' }}
transition={{ duration: TOAST_LIFETIME_MS / 1000, ease: 'linear' }}
/>
<div className="p-3 pr-8">
{/* Header */}
<div className="flex items-center gap-2 mb-1.5">
<span
className="text-[9px] font-bold tracking-[0.2em] px-1.5 py-0.5 rounded-sm"
style={{
background: `${color}20`,
color: color,
border: `1px solid ${color}40`,
}}
>
{label}
</span>
<span className="text-[9px] text-[var(--text-muted)] tracking-wider uppercase">
LVL {toast.risk_score}/10
</span>
</div>
{/* Title */}
<div
className="text-[11px] text-[var(--text-primary)] leading-tight mb-1"
style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}
>
{toast.title}
</div>
{/* Source */}
<div className="text-[9px] text-[var(--text-muted)] tracking-wider uppercase">
{toast.source}
</div>
</div>
{/* Dismiss button */}
<button
className="absolute top-2 right-2 text-[var(--text-muted)] hover:text-white transition-colors text-xs font-bold"
onClick={(e) => {
e.stopPropagation();
onDismiss(toast.id);
}}
>
×
</button>
</div>
</motion.div>
);
}
export default function AlertToast({
toasts,
onDismiss,
@@ -28,87 +147,14 @@ export default function AlertToast({
return (
<div className="fixed top-16 right-[440px] z-[9500] flex flex-col gap-2 pointer-events-none max-w-[380px]">
<AnimatePresence mode="popLayout">
{toasts.map((toast) => {
const color = getRiskColor(toast.risk_score);
const label = getRiskLabel(toast.risk_score);
return (
<motion.div
key={toast.id}
layout
initial={{ opacity: 0, x: 100, scale: 0.9 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 100, scale: 0.9 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="pointer-events-auto cursor-pointer"
onClick={() => {
if (onFlyTo && toast.lat && toast.lng) {
onFlyTo(toast.lat, toast.lng);
}
onDismiss(toast.id);
}}
>
<div
className="relative bg-[rgba(5,5,5,0.96)] backdrop-blur-sm rounded-sm overflow-hidden font-mono"
style={{
borderLeft: `3px solid ${color}`,
boxShadow: `0 0 20px ${color}40, 0 4px 12px rgba(0,0,0,0.5)`,
}}
>
{/* Progress bar */}
<motion.div
className="absolute top-0 left-0 h-[2px]"
style={{ background: color }}
initial={{ width: '100%' }}
animate={{ width: '0%' }}
transition={{ duration: 5, ease: 'linear' }}
/>
<div className="p-3 pr-8">
{/* Header */}
<div className="flex items-center gap-2 mb-1.5">
<span
className="text-[9px] font-bold tracking-[0.2em] px-1.5 py-0.5 rounded-sm"
style={{
background: `${color}20`,
color: color,
border: `1px solid ${color}40`,
}}
>
{label}
</span>
<span className="text-[9px] text-[var(--text-muted)] tracking-wider uppercase">
LVL {toast.risk_score}/10
</span>
</div>
{/* Title */}
<div
className="text-[11px] text-[var(--text-primary)] leading-tight mb-1"
style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}
>
{toast.title}
</div>
{/* Source */}
<div className="text-[9px] text-[var(--text-muted)] tracking-wider uppercase">
{toast.source}
</div>
</div>
{/* Dismiss button */}
<button
className="absolute top-2 right-2 text-[var(--text-muted)] hover:text-white transition-colors text-xs font-bold"
onClick={(e) => {
e.stopPropagation();
onDismiss(toast.id);
}}
>
×
</button>
</div>
</motion.div>
);
})}
{toasts.map((toast) => (
<ToastCard
key={toast.id}
toast={toast}
onDismiss={onDismiss}
onFlyTo={onFlyTo}
/>
))}
</AnimatePresence>
</div>
);