Files
cinny/src/app/features/toast/LotusToastContainer.tsx
T
jared 127e783f66 fix(ui): toast cards render on stock themes; gate TDS glow (native-cinny audit 4/N)
LotusToastContainer was styled entirely with --lt-* CSS vars but rendered
unconditionally (not gated on lotusTerminal). Those vars only exist inside the
Lotus Terminal theme's scoped block with no global fallback, so in-app toast
notifications rendered with undefined background/border/colors on every stock
Cinny theme. Now the card uses folds tokens (color.Surface.*/Primary.*,
config.radii/space/borderWidth, color.Other.Shadow) by default, keeping the TDS
--lt-* glow/accents only when lotusTerminal is active. The raw <button> dismiss
control is now a folds IconButton.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 22:06:33 -04:00

232 lines
6.6 KiB
TypeScript

import React, { useEffect, useRef, CSSProperties } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { color, config, Icon, IconButton, Icons } from 'folds';
import { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
// Inject the keyframe animation once
const STYLE_ID = 'lotus-toast-keyframes';
function ensureKeyframes() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = `
@keyframes lotusToastIn {
from { transform: translateX(120%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
@keyframes lotusToastIn {
from { opacity: 0; }
to { opacity: 1; }
}
}
`;
document.head.appendChild(style);
}
type ToastCardProps = {
toast: ToastNotif;
};
function ToastCard({ toast }: ToastCardProps) {
const dismiss = useSetAtom(dismissToastAtom);
// Lotus Terminal (TDS) gets its bespoke glow/accents; every other theme uses
// folds tokens so toasts render correctly on stock Cinny themes (the --lt-*
// vars only exist while Terminal mode is active).
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (toast.sticky) return;
timerRef.current = setTimeout(() => {
dismiss(toast.id);
}, 4000);
return () => {
if (timerRef.current !== null) clearTimeout(timerRef.current);
};
}, [dismiss, toast.id, toast.sticky]);
const handleCardClick = () => {
if (toast.onClick) {
toast.onClick();
} else {
// window.location.hash setter auto-prepends '#', so values must not include it
window.location.hash = toast.hashPath ?? `/room/${toast.roomId}`;
}
dismiss(toast.id);
};
const handleDismiss = (e: React.MouseEvent) => {
e.stopPropagation();
dismiss(toast.id);
};
const accent = toast.sticky ? color.Primary.Main : color.Surface.OnContainer;
const cardStyle: CSSProperties = {
position: 'relative',
background: lotusTerminal ? 'var(--lt-bg-card)' : color.Surface.Container,
border: `${config.borderWidth.B300} solid ${
lotusTerminal
? toast.sticky
? 'var(--lt-accent-cyan-border)'
: 'var(--lt-border-color)'
: toast.sticky
? color.Primary.Main
: color.Surface.ContainerLine
}`,
borderRadius: config.radii.R400,
padding: `${config.space.S300} ${config.space.S400}`,
minWidth: '280px',
maxWidth: '340px',
boxShadow: lotusTerminal
? toast.sticky
? 'var(--lt-box-glow-cyan)'
: 'var(--lt-box-glow-orange)'
: `0 8px 24px ${color.Other.Shadow}`,
cursor: 'pointer',
animation: 'lotusToastIn 0.2s ease-out both',
userSelect: 'none',
};
const rowStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: config.space.S200,
marginRight: config.space.S500,
};
const avatarStyle: CSSProperties = {
width: '24px',
height: '24px',
borderRadius: '50%',
objectFit: 'cover',
flexShrink: 0,
};
const initialsStyle: CSSProperties = {
width: '24px',
height: '24px',
borderRadius: '50%',
background: lotusTerminal ? 'var(--lt-accent-orange-dim)' : color.Primary.Container,
border: `${config.borderWidth.B300} solid ${
lotusTerminal ? 'var(--lt-accent-orange-border)' : color.Primary.ContainerLine
}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '10px',
fontWeight: 700,
color: lotusTerminal ? 'var(--lt-accent-orange)' : color.Primary.OnContainer,
flexShrink: 0,
};
const nameStyle: CSSProperties = {
color: lotusTerminal
? toast.sticky
? 'var(--lt-accent-cyan)'
: 'var(--lt-accent-orange)'
: accent,
fontWeight: 600,
fontSize: '0.85rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
const bodyStyle: CSSProperties = {
color: lotusTerminal ? 'var(--lt-text-primary)' : color.Surface.OnContainer,
fontSize: '0.82rem',
margin: '4px 0 2px',
overflow: 'hidden',
...(toast.sticky
? { whiteSpace: 'normal', lineHeight: 1.4 }
: { textOverflow: 'ellipsis', whiteSpace: 'nowrap' }),
};
const roomNameStyle: CSSProperties = {
color: lotusTerminal ? 'var(--lt-text-secondary)' : color.SurfaceVariant.OnContainer,
fontSize: '0.75rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
const initials = toast.displayName
.split(' ')
.slice(0, 2)
.map((w) => w[0] ?? '')
.join('')
.toUpperCase();
return (
<div
role="button"
tabIndex={0}
style={cardStyle}
onClick={handleCardClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') handleCardClick();
}}
aria-label={`Notification from ${toast.displayName} in ${toast.roomName}`}
>
<span style={{ position: 'absolute', top: config.space.S100, right: config.space.S100 }}>
<IconButton
type="button"
size="300"
radii="300"
variant="Surface"
fill="None"
onClick={handleDismiss}
aria-label="Dismiss notification"
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
</span>
<div style={rowStyle}>
{toast.avatarUrl ? (
<img src={toast.avatarUrl} alt="" style={avatarStyle} aria-hidden="true" />
) : (
<div style={initialsStyle} aria-hidden="true">
{initials || '?'}
</div>
)}
<span style={nameStyle}>{toast.displayName}</span>
</div>
<div style={bodyStyle}>{toast.body}</div>
<div style={roomNameStyle}>{toast.roomName}</div>
</div>
);
}
export function LotusToastContainer() {
useEffect(() => {
ensureKeyframes();
}, []);
const toasts = useAtomValue(toastQueueAtom);
if (toasts.length === 0) return null;
const containerStyle: CSSProperties = {
position: 'fixed',
bottom: '1.5rem',
right: '1.5rem',
zIndex: 10001,
display: 'flex',
flexDirection: 'column',
gap: config.space.S200,
pointerEvents: 'auto',
};
return (
<div style={containerStyle} aria-live="polite" aria-label="Notifications">
{toasts.map((toast) => (
<ToastCard key={toast.id} toast={toast} />
))}
</div>
);
}