// The queue's rules, with no clock and no DOM: what a new message does to the list, which toasts // are on screen, and which have run out of time. Pure, so every one of them is provable — the // store beside this file only supplies elapsed milliseconds. import type { Tone } from '../components/variants'; /** * How long a toast dwells, as a TOKEN rather than a per-call number of milliseconds. A call site * choosing `6500` is a design decision made forty times with forty different answers; a call site * choosing `long` is one that can be re-tuned in this file. * * `sticky` never expires and is for a message the user must act on — it still needs a dismiss * control, because a toast may never hold the only copy of information. */ export type ToastDwell = 'short' | 'long' | 'sticky'; /** WCAG 2.2 2.2.1 gives an adjustable/extendable timing; the pause seams in the store are that. */ export const TOAST_DWELL_MS = Object.freeze>({ short: 4_000, long: 8_000, sticky: 0, }); /** * `Object.hasOwn`, never the read alone. A `dwell` reaches here off a `ToastInput`, which is an * app's own object and may have been parsed from a config file — and `TOAST_DWELL_MS['toString']` * answers a FUNCTION where an unknown key must answer a number, which would then be spent as * milliseconds. The fallback is the default a toast with no `dwell` already gets. */ export function dwellMs(dwell: ToastDwell): number { return Object.hasOwn(TOAST_DWELL_MS, dwell) ? TOAST_DWELL_MS[dwell] : TOAST_DWELL_MS.short; } /** * How many toasts are on screen at once. The rest stay queued and start their dwell only when * they surface — a stack that grows without bound covers the page it is reporting on, and every * message below the fold burns its timer unread. */ export const TOAST_MAX_VISIBLE = 3; /** * The one affordance a toast may carry, and it is undo-shaped on purpose: a toast is transient, so * anything reachable ONLY from it is unreachable to a keyboard user who was typing elsewhere when * it arrived. An undo is safe because not taking it is the status quo. */ export interface ToastAction { /** Already-translated. */ readonly label: string; readonly onAction: () => void; } export interface ToastInput { /** Already-translated message. The design system never phrases an app's notification. */ readonly message: string; /** Already-translated. */ readonly title?: string | undefined; readonly tone?: Tone | undefined; readonly dwell?: ToastDwell | undefined; readonly action?: ToastAction | undefined; } export interface ToastItem { readonly id: string; readonly message: string; readonly title: string | undefined; readonly tone: Tone; readonly dwell: ToastDwell; readonly action: ToastAction | undefined; /** Milliseconds left of the dwell. `0` for a sticky toast, which never counts down. */ readonly remainingMs: number; } /** * Why the countdown is stopped. Three INDEPENDENT reasons, never one boolean: a pointer leaving * while the tab is still in the background must not restart a dwell the reader cannot see. */ export type ToastHold = 'pointer' | 'focus' | 'hidden'; /** Arrival order, oldest first: the first `TOAST_MAX_VISIBLE` are on screen. */ export interface ToastQueue { readonly items: readonly ToastItem[]; } export const EMPTY_TOAST_QUEUE: ToastQueue = Object.freeze({ items: Object.freeze([]) }); /** * What makes two toasts the same message. A retry loop firing "Could not save" every 800ms is the * case this exists for: five identical stacked cards say nothing the first one did not, and they * push every other message out of the cap. * * Named an IDENTITY, and not a key or a signature: it is display text compared with `===` in a * render path, and `bun run secret-compare` decides from the NAME whether a `===` is a timing * leak. `toastKey` matched its `Key` suffix and `toastSignature` matched `Signature`; both read as * a credential check to a rule that only has a name to go on, and a rule that has to be argued * with is a rule somebody switches off. */ export function toastIdentity(item: ToastInput): string { return `${item.tone ?? 'neutral'}\0${item.title ?? ''}\0${item.message}`; } export function toastItem(id: string, input: ToastInput): ToastItem { const dwell = input.dwell ?? 'short'; return { id, message: input.message, title: input.title, tone: input.tone ?? 'neutral', dwell, action: input.action, remainingMs: dwellMs(dwell), }; } export interface ToastAdded { readonly queue: ToastQueue; /** The id the caller can dismiss. A deduped message answers the LIVE toast's id, never a new one. */ readonly id: string; } /** * Add, or refresh the identical toast already queued. Refreshing restores its dwell and leaves it * where it is: promoting it to the end would make a repeating error walk up the stack, and the * reader is looking at a card that keeps moving. */ export function addToast(queue: ToastQueue, item: ToastItem): ToastAdded { const identity = toastIdentity(item); const at = queue.items.findIndex((held) => toastIdentity(held) === identity); if (at === -1) return { queue: { items: [...queue.items, item] }, id: item.id }; const existing = queue.items[at] as ToastItem; const items = [...queue.items]; items[at] = { ...existing, remainingMs: dwellMs(existing.dwell) }; return { queue: { items }, id: existing.id }; } export function dismissToast(queue: ToastQueue, id: string): ToastQueue { const items = queue.items.filter((item) => item.id !== id); return items.length === queue.items.length ? queue : { items }; } /** On screen. */ export function visibleToasts(queue: ToastQueue): readonly ToastItem[] { return queue.items.slice(0, TOAST_MAX_VISIBLE); } /** Queued behind the cap. Their dwell has not started — a message nobody can read is not shown. */ export function collapsedToasts(queue: ToastQueue): readonly ToastItem[] { return queue.items.slice(TOAST_MAX_VISIBLE); } /** * Spend `elapsedMs` of dwell on the VISIBLE, non-sticky toasts and drop the ones that ran out. * Only the visible ones, which is what makes the cap a queue rather than a crop. */ export function advanceToasts(queue: ToastQueue, elapsedMs: number): ToastQueue { if (!(elapsedMs > 0)) return queue; const visible = new Set(visibleToasts(queue).map((item) => item.id)); let changed = false; const items: ToastItem[] = []; for (const item of queue.items) { if (!visible.has(item.id) || item.dwell === 'sticky') { items.push(item); continue; } changed = true; const remainingMs = item.remainingMs - elapsedMs; if (remainingMs > 0) items.push({ ...item, remainingMs }); } return changed ? { items } : queue; } /** When the next visible toast expires, or `null` when nothing is counting down. */ export function nextExpiryMs(queue: ToastQueue): number | null { let soonest: number | null = null; for (const item of visibleToasts(queue)) { if (item.dwell === 'sticky') continue; if (soonest === null || item.remainingMs < soonest) soonest = item.remainingMs; } return soonest; }