Skip to content

Commit 779a8ed

Browse files
waleedlatif1claude
andcommitted
feat(emcn/toast): tint variant icons; polish clear-all, countdown reset, teardown
- Req 6: per-variant icon tint (error/warning/success/info) from the shared intent palette, so error vs info is distinguishable pre-attentively in a mixed stack instead of only by reading the copy. Default stays neutral. - H3: wrap the stack in AnimatePresence so clear-all / route-change fades the frozen stack out instead of cutting abruptly; per-card exits still play for single dismissals. No pointer-events change (no hover regression). - Countdown reset now keys on a monotonic arrival counter, so dismissing the front card no longer restarts the whole stack's auto-dismiss timer. - Provider teardown only nulls the global toast bindings if they're still its own, guarding against an out-of-order unmount with a second provider. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 59a1b9b commit 779a8ed

1 file changed

Lines changed: 105 additions & 63 deletions

File tree

  • apps/sim/components/emcn/components/toast

apps/sim/components/emcn/components/toast/toast.tsx

Lines changed: 105 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,10 @@ const CONCENTRIC_RADIUS_PX = 16
8282
type 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
*/
8990
const 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+
97111
interface 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

Comments
 (0)