Files
Shadowbroker/frontend/src/components/AlertToast.tsx
T
Shadowbroker 421682c447 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>
2026-05-19 00:49:36 -06:00

162 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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';
if (score >= 4) return '#eab308';
return '#22d3ee';
}
function getRiskLabel(score: number): string {
if (score >= 9) return 'CRITICAL';
if (score >= 7) return 'HIGH';
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,
onFlyTo,
}: {
toasts: ToastItem[];
onDismiss: (id: string) => void;
onFlyTo?: (lat: number, lng: number) => void;
}) {
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) => (
<ToastCard
key={toast.id}
toast={toast}
onDismiss={onDismiss}
onFlyTo={onFlyTo}
/>
))}
</AnimatePresence>
</div>
);
}