// `ToastStore` is the single transient-notification primitive for the // SvelteKit shell. Phase 24 ships it together with the push-event // dispatch: the per-game layout shows one `Turn N is ready. View now.` // toast on a verified `game.turn.ready` event. Later phases reuse the // same store for mail / battle / lobby toasts (PLAN.md ยง"cross-cutting // shell"). // // The store keeps **one** active toast at a time: a fresh `show()` // replaces the previous descriptor. This matches the UX intent of // "one loud notification at a time" โ€” the rare cases where several // events arrive in quick succession are still observable, because // each replacement re-arms the timer and the user sees every payload // in turn. import type { TranslationKey } from "./i18n/index.svelte"; /** * ToastDescriptor describes one toast in flight. `messageKey` and * `actionLabelKey` are typed against the i18n catalog so a missing * translation key fails at compile time. `durationMs === null` (or * `undefined`) makes the toast sticky โ€” the user must dismiss it * through the action button or another `show()` call. */ export interface ToastDescriptor { id: string; messageKey: TranslationKey; messageParams?: Record; actionLabelKey?: TranslationKey; onAction?: () => void; durationMs?: number | null; } class ToastStore { current: ToastDescriptor | null = $state(null); private timer: ReturnType | null = null; private counter = 0; /** * show replaces the active toast with descriptor and returns its * fresh id. Pass that id to `dismiss(id)` from a delayed callback * to avoid dismissing a newer toast that already took its slot. */ show(descriptor: Omit): string { this.clearTimer(); this.counter += 1; const id = String(this.counter); const full: ToastDescriptor = { ...descriptor, id }; this.current = full; if ( full.durationMs !== null && full.durationMs !== undefined && full.durationMs > 0 ) { const duration = full.durationMs; this.timer = setTimeout(() => { this.dismiss(id); }, duration); } return id; } /** * dismiss clears the active toast. With an id, the call is a * no-op when the active toast has a different id โ€” this guards * the auto-dismiss timer from clobbering a newer descriptor. */ dismiss(id?: string): void { if ( id !== undefined && (this.current === null || this.current.id !== id) ) { return; } this.clearTimer(); this.current = null; } /** * resetForTests forgets every in-flight descriptor and the id * counter. Production code never calls this. */ resetForTests(): void { this.clearTimer(); this.current = null; this.counter = 0; } private clearTimer(): void { if (this.timer !== null) { clearTimeout(this.timer); this.timer = null; } } } export const toast = new ToastStore();