// 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 && ( )}
); } // ── 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');