@@ -82,9 +82,10 @@ const CONCENTRIC_RADIUS_PX = 16
8282type ToastVariant = 'default' | 'info' | 'success' | 'warning' | 'error'
8383
8484/**
85- * Leading icon per variant. Rendered inline at the start of the message in a
86- * neutral icon color (no tint, no badge) — variant intent reads from the icon
87- * shape and the message copy rather than color.
85+ * Leading icon per variant, rendered inline at the start of the message. The
86+ * icon shape signals intent; {@link VARIANT_ICON_COLOR} adds a matching tint so
87+ * an error vs. an info toast is distinguishable pre-attentively in a mixed
88+ * stack, not only by reading the copy.
8889 */
8990const VARIANT_ICON : Record < ToastVariant , ComponentType < SVGProps < SVGSVGElement > > > = {
9091 default : Bell ,
@@ -94,6 +95,19 @@ const VARIANT_ICON: Record<ToastVariant, ComponentType<SVGProps<SVGSVGElement>>>
9495 error : CircleAlert ,
9596}
9697
98+ /**
99+ * Per-variant icon tint, drawn from the shared intent palette (the same tokens
100+ * the badge / callout system uses) so the toast reads as part of the design
101+ * language. `default` stays neutral — it carries no intent.
102+ */
103+ const VARIANT_ICON_COLOR : Record < ToastVariant , string > = {
104+ default : 'text-[var(--text-icon)]' ,
105+ info : 'text-[var(--badge-blue-text)]' ,
106+ success : 'text-[var(--text-success)]' ,
107+ warning : 'text-[var(--badge-amber-text)]' ,
108+ error : 'text-[var(--text-error)]' ,
109+ }
110+
97111interface ToastAction {
98112 label : string
99113 onClick : ( ) => void
@@ -404,7 +418,12 @@ function ToastItem({ toast: t, geometry, reduceMotion, onDismiss, onMeasure }: T
404418 clampLines = { 2 }
405419 lineHeightPx = { 20 }
406420 leadingIcon = {
407- < Icon className = 'mr-[5px] inline-block size-[14px] translate-y-[-2px] align-middle text-[var(--text-icon)]' />
421+ < Icon
422+ className = { cn (
423+ 'mr-[5px] inline-block size-[14px] translate-y-[-2px] align-middle' ,
424+ VARIANT_ICON_COLOR [ t . variant ]
425+ ) }
426+ />
408427 }
409428 className = 'font-medium text-[14px] text-[var(--text-primary)] leading-5'
410429 reduceMotion = { reduceMotion }
@@ -599,6 +618,12 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
599618 const [ heights , setHeights ] = useState < Record < string , number > > ( { } )
600619 const [ expanded , setExpanded ] = useState ( false )
601620 const [ mounted , setMounted ] = useState ( false )
621+ /**
622+ * Monotonic count of toasts ever added. Drives the stack-dismiss countdown
623+ * reset: it changes only on a NEW arrival, so dismissing the front card no
624+ * longer restarts the whole stack's timer (which keying on the newest id did).
625+ */
626+ const [ arrivalCount , setArrivalCount ] = useState ( 0 )
602627 const timersRef = useRef ( new Map < string , ReturnType < typeof setTimeout > > ( ) )
603628
604629 useEffect ( ( ) => {
@@ -622,6 +647,7 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
622647 duration : input . duration ?? ( input . action ? 0 : AUTO_DISMISS_MS ) ,
623648 }
624649 setToasts ( ( prev ) => [ ...prev , data ] . slice ( - STACK_LIMIT ) )
650+ setArrivalCount ( ( c ) => c + 1 )
625651 return id
626652 } , [ ] )
627653
@@ -728,13 +754,16 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
728754 const toastFn = useRef < ToastFn > ( createToastFn ( addToast ) )
729755
730756 useEffect ( ( ) => {
731- globalToast = toastFn . current
757+ const fn = toastFn . current
758+ globalToast = fn
732759 globalDismiss = dismissToast
733760 globalDismissAll = dismissAllToasts
734761 return ( ) => {
735- globalToast = null
736- globalDismiss = null
737- globalDismissAll = null
762+ // Only relinquish the bindings if they're still ours — guards against a
763+ // second provider (or an out-of-order unmount) nulling a live binding.
764+ if ( globalToast === fn ) globalToast = null
765+ if ( globalDismiss === dismissToast ) globalDismiss = null
766+ if ( globalDismissAll === dismissAllToasts ) globalDismissAll = null
738767 }
739768 } , [ dismissToast , dismissAllToasts ] )
740769
@@ -776,62 +805,75 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
776805 return (
777806 < ToastContext . Provider value = { ctx } >
778807 { children }
779- { mounted && toasts . length > 0
808+ { mounted
780809 ? createPortal (
781- < motion . ol
782- aria-live = 'polite'
783- aria-label = 'Notifications'
784- className = 'fixed z-[var(--z-toast)] m-0 list-none p-0'
785- style = { {
786- right : isWorkflowPage ? 'calc(var(--panel-width) + 16px)' : '16px' ,
787- bottom : isWorkflowPage ? 'calc(var(--terminal-height) + 16px)' : '16px' ,
788- width : TOAST_WIDTH ,
789- height : containerHeight ,
790- } }
791- >
792- < AnimatePresence initial = { false } >
793- { toasts . length >= STACK_DISMISS_THRESHOLD ? (
794- < StackDismiss
795- key = 'dismiss-all'
796- paused = { expanded }
797- autoDismiss = { ! hasPersistentToast }
798- reduceMotion = { reduceMotion }
799- resetKey = { toasts [ toasts . length - 1 ] ?. id ?? '' }
800- onDismiss = { dismissAllToasts }
801- />
802- ) : null }
803- </ AnimatePresence >
804- { /*
805- * Expand-on-hover is scoped to the cards only. The dismiss control
806- * sits outside this region, so hovering (or resting near) it pauses
807- * the countdown without fanning the stack open — the stack stays
808- * collapsed until the cards themselves are hovered.
809- */ }
810- < div
811- onMouseEnter = { ( ) => setExpanded ( true ) }
812- onMouseLeave = { ( ) => setExpanded ( false ) }
813- onFocusCapture = { ( ) => setExpanded ( true ) }
814- onBlurCapture = { ( event ) => {
815- if ( ! event . currentTarget . contains ( event . relatedTarget as Node | null ) ) {
816- setExpanded ( false )
817- }
818- } }
819- className = 'absolute inset-0'
820- >
821- < AnimatePresence >
822- { layout . map ( ( { toast, geometry } ) => (
823- < ToastItem
824- key = { toast . id }
825- toast = { toast }
826- geometry = { geometry }
827- reduceMotion = { reduceMotion }
828- onDismiss = { dismissToast }
829- onMeasure = { measureToast }
830- />
831- ) ) }
832- </ AnimatePresence >
833- </ div >
834- </ motion . ol > ,
810+ // AnimatePresence keeps the stack mounted through its exit, so
811+ // clearing all toasts at once (dismiss-all / route change) fades the
812+ // frozen stack out instead of cutting it abruptly. Per-card exits
813+ // still play for single dismissals (the inner AnimatePresence).
814+ < AnimatePresence >
815+ { toasts . length > 0 ? (
816+ < motion . ol
817+ key = 'toast-stack'
818+ aria-live = 'polite'
819+ aria-label = 'Notifications'
820+ className = 'fixed z-[var(--z-toast)] m-0 list-none p-0'
821+ exit = { {
822+ opacity : 0 ,
823+ transition : reduceMotion ? { duration : 0 } : { duration : 0.2 , ease : 'easeIn' } ,
824+ } }
825+ style = { {
826+ right : isWorkflowPage ? 'calc(var(--panel-width) + 16px)' : '16px' ,
827+ bottom : isWorkflowPage ? 'calc(var(--terminal-height) + 16px)' : '16px' ,
828+ width : TOAST_WIDTH ,
829+ height : containerHeight ,
830+ } }
831+ >
832+ < AnimatePresence initial = { false } >
833+ { toasts . length >= STACK_DISMISS_THRESHOLD ? (
834+ < StackDismiss
835+ key = 'dismiss-all'
836+ paused = { expanded }
837+ autoDismiss = { ! hasPersistentToast }
838+ reduceMotion = { reduceMotion }
839+ resetKey = { String ( arrivalCount ) }
840+ onDismiss = { dismissAllToasts }
841+ />
842+ ) : null }
843+ </ AnimatePresence >
844+ { /*
845+ * Expand-on-hover is scoped to the cards only. The dismiss control
846+ * sits outside this region, so hovering (or resting near) it pauses
847+ * the countdown without fanning the stack open — the stack stays
848+ * collapsed until the cards themselves are hovered.
849+ */ }
850+ < div
851+ onMouseEnter = { ( ) => setExpanded ( true ) }
852+ onMouseLeave = { ( ) => setExpanded ( false ) }
853+ onFocusCapture = { ( ) => setExpanded ( true ) }
854+ onBlurCapture = { ( event ) => {
855+ if ( ! event . currentTarget . contains ( event . relatedTarget as Node | null ) ) {
856+ setExpanded ( false )
857+ }
858+ } }
859+ className = 'absolute inset-0'
860+ >
861+ < AnimatePresence >
862+ { layout . map ( ( { toast, geometry } ) => (
863+ < ToastItem
864+ key = { toast . id }
865+ toast = { toast }
866+ geometry = { geometry }
867+ reduceMotion = { reduceMotion }
868+ onDismiss = { dismissToast }
869+ onMeasure = { measureToast }
870+ />
871+ ) ) }
872+ </ AnimatePresence >
873+ </ div >
874+ </ motion . ol >
875+ ) : null }
876+ </ AnimatePresence > ,
835877 document . body
836878 )
837879 : null }
0 commit comments