// Toast — couche de notification globale.
// API : showToast('message', 'success' | 'error' | 'info')
// Stack de 3 max (le plus ancien saute), auto-dismiss 3s, tap pour fermer.
// Monté DANS l'écran de l'iPhone (pas dans document.body) pour rester dans le cadre.
const TOAST_MAX = 3;
const TOAST_DURATION = 3000;
const TOAST_EXIT_MS = 220;
const TS_VT = window.VT || {};
const TS_SURF = TS_VT.surface || '#ffffff';
const TS_INK = TS_VT.ink || '#0a0a0a';
const TS_HAIR2 = TS_VT.hair2 || 'rgba(10,10,10,0.10)';
const TS_SHADOW = TS_VT.shadowCard || '0 2px 8px rgba(10,10,10,0.06), 0 8px 24px rgba(10,10,10,0.04)';
const TOAST_TYPES = {
success: {
color: TS_VT.success || '#059669',
tint: TS_VT.successTint || 'rgba(5,150,105,0.10)',
icon: ,
},
error: {
color: TS_VT.danger || '#b91c1c',
tint: 'rgba(185,28,28,0.10)',
icon: <> >,
},
info: {
color: TS_VT.medBlue || '#3d6b8f',
tint: TS_VT.medBlueTint || 'rgba(61,107,143,0.10)',
icon: <> >,
},
};
// ── Store hors React : showToast doit être appelable depuis n'importe où,
// sans contexte ni prop drilling (le codebase n'a pas de provider).
const ToastStore = {
items: [],
seq: 0,
listeners: new Set(),
timers: new Map(),
subscribe(fn) {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
},
emit() {
const snapshot = this.items;
this.listeners.forEach((fn) => fn(snapshot));
},
// opts : { duration, action: { label, onClick } }
push(message, type, opts = {}) {
const id = ++this.seq;
const kind = TOAST_TYPES[type] ? type : 'info';
const duration = Number(opts.duration) > 0 ? Number(opts.duration) : TOAST_DURATION;
const next = [...this.items, {
id, message: String(message), type: kind, leaving: false,
action: opts.action || null,
}];
// Au-delà de 3, on retire les plus anciens — et on tue leurs timers pour
// ne pas laisser un dismiss orphelin se déclencher sur un id recyclé.
while (next.length > TOAST_MAX) {
const dropped = next.shift();
this.clearTimer(dropped.id);
}
this.items = next;
this.emit();
this.timers.set(id, setTimeout(() => this.dismiss(id), duration));
return id;
},
dismiss(id) {
const target = this.items.find((t) => t.id === id);
if (!target || target.leaving) return;
this.clearTimer(id);
this.items = this.items.map((t) => (t.id === id ? { ...t, leaving: true } : t));
this.emit();
this.timers.set(id, setTimeout(() => {
this.items = this.items.filter((t) => t.id !== id);
this.timers.delete(id);
this.emit();
}, TOAST_EXIT_MS));
},
clearTimer(id) {
const t = this.timers.get(id);
if (t) clearTimeout(t);
this.timers.delete(id);
},
};
function showToast(message, type = 'info', opts) {
return ToastStore.push(message, type, opts);
}
// ── Confirmer une action sans notification ──
// La réussite d'une action ne se dit pas en bas de l'écran : elle se lit sur le
// bouton qui l'a portée, ou dans l'écran qui a changé. Ce petit état sert aux
// actions dont le résultat n'est visible nulle part ailleurs — une copie dans le
// presse-papiers, par exemple : le bouton porte sa confirmation quelques
// secondes, puis redevient un bouton.
// La règle est écrite dans `docs/product/ACTION_FEEDBACK.md`.
const ACTION_DONE_MS = 1600;
function useActionDone(durationMs = ACTION_DONE_MS) {
const [done, setDone] = React.useState(null);
const timer = React.useRef(null);
React.useEffect(() => () => window.clearTimeout(timer.current), []);
const mark = React.useCallback((key = true) => {
window.clearTimeout(timer.current);
setDone(key);
timer.current = window.setTimeout(() => setDone(null), durationMs);
}, [durationMs]);
return [done, mark];
}
// ── Une carte ──
function ToastCard({ toast }) {
const kind = TOAST_TYPES[toast.type] || TOAST_TYPES.info;
return (
ToastStore.dismiss(toast.id)}
style={{
pointerEvents: 'auto',
display: 'flex', alignItems: 'center', gap: 10,
padding: '11px 14px',
background: TS_SURF,
border: `1px solid ${TS_HAIR2}`,
borderRadius: 16,
boxShadow: TS_SHADOW,
cursor: 'pointer',
animation: `${toast.leaving ? 'toastOut' : 'toastIn'} ${toast.leaving ? TOAST_EXIT_MS : 260}ms cubic-bezier(.16,1,.3,1) forwards`,
}}>
{kind.icon}
{toast.message}
{toast.action && (
{
// Sans cela, le clic remonterait à la carte qui ferme le toast.
e.stopPropagation();
toast.action.onClick();
ToastStore.dismiss(toast.id);
}}
className="press"
style={{
flexShrink: 0, background: 'none', border: 'none', padding: '2px 2px 2px 6px',
cursor: 'pointer', fontFamily: 'inherit',
fontSize: 12.5, fontWeight: 700, color: kind.color,
}}>{toast.action.label}
)}
);
}
// ── Le host : à monter une seule fois, dans le conteneur de l'écran ──
function ToastHost({ bottom = 84 }) {
const [items, setItems] = React.useState(ToastStore.items);
React.useEffect(() => ToastStore.subscribe(setItems), []);
return (
{items.map((t) => )}
);
}
// ── Copier dans le presse-papiers ──
// `navigator.clipboard.writeText` peut ne jamais se conclure : permission en
// attente de réponse, fenêtre sans le focus. Sans délai de garde, le bouton qui
// l'appelle reste muet — ni confirmation, ni refus — et rien ne dit au
// praticien que rien n'a été copié.
const CLIPBOARD_TIMEOUT_MS = 4000;
function copyToClipboard(text) {
if (!navigator.clipboard?.writeText) {
return Promise.reject(new Error('presse-papier indisponible'));
}
return Promise.race([
navigator.clipboard.writeText(text),
new Promise((_, reject) => window.setTimeout(
() => reject(new Error('presse-papier sans réponse')), CLIPBOARD_TIMEOUT_MS,
)),
]);
}
window.showToast = showToast;
window.useActionDone = useActionDone;
window.copyToClipboard = copyToClipboard;
window.ToastHost = ToastHost;
console.log('%c[VASCO] toast.jsx chargé — build 2026.08.04', 'color:#3d6b8f;font-weight:bold');