codeburn/app/renderer/lib/toast.ts
iamtoruk daf944e714 feat(app): motion layer (gsap) — count-ups, chart grow-in, section fade, skeletons, toasts
- one gate (motionEnabled): off under reduced-motion, missing matchMedia,
  and vitest; every path checks it first
- mount/filter-change only: count-up and bar grow-in key off the
  period|provider|range key, so 30s poll refreshes snap values silently
  instead of re-animating
- first-load skeleton shimmer replaces bare scanning text (kept sr-only
  for screen readers); slide-in toast host for Settings/export feedback
  (validation errors stay inline); CSS hover-lift + press micro-
  interactions, all with a reduced-motion escape hatch
- gsap 3.15.0 + @gsap/react 2.1.2 (+74KB raw JS; G4 flame work shares it)

244/244, typecheck + build green.
2026-07-16 04:39:09 -07:00

76 lines
1.8 KiB
TypeScript

export type ToastKind = 'ok' | 'error'
export type Toast = { id: number; text: string; kind: ToastKind }
let current: Toast | null = null
let seq = 0
let timer: ReturnType<typeof setTimeout> | null = null
const hosts: number[] = []
let hostSeq = 0
const listeners = new Set<() => void>()
function emit(): void {
for (const listener of listeners) listener()
}
function clearTimer(): void {
if (timer) {
clearTimeout(timer)
timer = null
}
}
/** Show a toast, replacing any current one (only ever one at a time), and start
* its auto-dismiss timer. */
export function showToast(text: string, kind: ToastKind = 'ok', durationMs = 3000): void {
seq += 1
current = { id: seq, text, kind }
clearTimer()
timer = setTimeout(() => {
current = null
timer = null
emit()
}, durationMs)
emit()
}
export function dismissToast(): void {
clearTimer()
current = null
emit()
}
export function getToast(): Toast | null {
return current
}
export function subscribeToasts(listener: () => void): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
/** Register a Toast host. Only the first-registered host renders (so App and a
* standalone-tested Settings can both mount one without doubling the toast). The
* store resets when the last host unmounts, keeping tests isolated. */
export function registerToastHost(): { id: number; release: () => void } {
const id = ++hostSeq
hosts.push(id)
emit()
return {
id,
release: () => {
const index = hosts.indexOf(id)
if (index >= 0) hosts.splice(index, 1)
if (hosts.length === 0) {
clearTimer()
current = null
}
emit()
},
}
}
export function isPrimaryHost(id: number): boolean {
return hosts.length > 0 && hosts[0] === id
}