// toast — the one place a failure gets SAID.
//
// THE PROBLEM. Every client-side write already handles its own failure correctly: actions.js puts the
// original state back rather than leaving a lie on screen. But the only place it says WHY is a `title`
// attribute and a 1.2s red flash. A `title` needs a hover, so on touch it does not exist at all, and no
// screen reader announces it. So the honest state of things was: the write failed, the UI silently
// reverted, and the user's reasonable reading is "my tap didn't register" — they tap again.
//
// Silent failure is the worst class of bug in this codebase and we keep re-learning it (hyper-social #24,
// #26: "everything we control reports success"). A control that reverts without saying why is the same
// mistake at the UI layer.
//
// SHAPE. One shared live region, appended once, reused. `role="status"` + `aria-live="polite"` so it is
// announced without stealing focus; assertive is reserved for errors, which ARE interruptions.
//
// Deliberately NOT a dependency of the no-JS path: this file only ever runs as progressive enhancement,
// and nothing renders differently when it is absent. If it fails to load, the pre-existing flash + title
// behaviour is still there — this is additive.
let host = null
function ensureHost () {
if (host && host.isConnected) return host
host = document.createElement('div')
host.className = 'toasts'
// The region must exist in the DOM BEFORE the message is written into it, or assistive tech may not
// announce a node that appeared already-populated. Hence a persistent host with children swapped in.
host.setAttribute('role', 'status')
host.setAttribute('aria-live', 'polite')
document.body.appendChild(host)
return host
}
// msg — plain text, never HTML (it carries server error strings, which are untrusted)
// kind — 'error' | 'info'. Errors are assertive and linger; info is polite and brief.
export function toast (msg, { kind = 'error', ms = null } = {}) {
const text = String(msg == null ? '' : msg).trim()
if (!text) return
const h = ensureHost()
h.setAttribute('aria-live', kind === 'error' ? 'assertive' : 'polite')
const el = document.createElement('div')
el.className = 'toast ' + (kind === 'error' ? 'toast-err' : 'toast-info')
el.textContent = text // textContent, NOT innerHTML — this string comes off the wire
// A dismiss control, because an error that vanishes on a timer is unreadable to anyone who reads
// slowly, and one that never vanishes is litter. Both: it auto-dismisses, and you can kill it early.
const close = document.createElement('button')
close.className = 'toast-x'
close.setAttribute('aria-label', 'dismiss')
close.textContent = '✕'
close.addEventListener('click', () => el.remove())
el.appendChild(close)
h.appendChild(el)
const life = Number.isFinite(ms) ? ms : (kind === 'error' ? 6000 : 3000)
setTimeout(() => { el.remove() }, life)
// Never let the region grow without bound if something fails in a loop.
while (h.children.length > 4) h.removeChild(h.firstChild)
}
export default toast