/** * LANE-UI — event toasts, bottom right. Max 4 on screen, 6s each, oldest evicted. * * Wall-clock timing is fine here: toasts are chrome, not sim. The determinism rule in * MASTERPLAN binds `src/sim/**`, and nothing in this file feeds back into it. */ import type { SimEvent } from '../contracts'; import { cls, el } from './dom'; import { eventToast } from './voice'; export interface Toasts { el: HTMLElement; push(ev: SimEvent, machineName: (entityId: number) => string): void; /** A toast that isn't a raw sim event — onboarding hints, deduped warnings, ceremony. */ pushMessage(msg: string, kind: string): void; /** Stand the stack off the bottom-right corner once the tuner dock moves in there. */ setStandoff(on: boolean): void; /** Called once per frame to expire old lines. */ update(nowMs: number): void; } const TTL_MS = 6000; const FADE_MS = 400; // must match the .fk-toast.is-out transition const MAX = 4; export function createToasts(): Toasts { const root = el('div'); root.id = 'fk-toasts'; const live: Array<{ node: HTMLElement; dieAt: number }> = []; function retire(entry: { node: HTMLElement; dieAt: number }) { cls(entry.node, 'is-out', true); setTimeout(() => entry.node.remove(), FADE_MS); } function emit(msg: string, kind: string) { const node = el('div', `fk-toast k-${kind}`, [msg]); root.append(node); live.push({ node, dieAt: performance.now() + TTL_MS }); while (live.length > MAX) retire(live.shift()!); } return { el: root, push(ev, machineName) { const msg = eventToast(ev, machineName); if (msg) emit(msg, ev.kind); }, pushMessage: emit, setStandoff(on) { cls(root, 'is-standoff', on); }, update(nowMs) { while (live.length && live[0].dieAt <= nowMs) retire(live.shift()!); }, }; }