codeburn/app/renderer/components/ToastHost.tsx
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

36 lines
1.1 KiB
TypeScript

import { useEffect, useReducer, useRef } from 'react'
import { createPortal } from 'react-dom'
import { getToast, isPrimaryHost, registerToastHost, subscribeToasts } from '../lib/toast'
import { motionClass } from '../lib/motion'
/** Bottom-right toast surface for action feedback. One at a time, role=status,
* slide-in when motion is on, auto-dismissed by the store. Ref-counted so only
* the primary host paints even if more than one is mounted. */
export function ToastHost() {
const idRef = useRef(0)
const [, force] = useReducer((n: number) => n + 1, 0)
useEffect(() => {
const { id, release } = registerToastHost()
idRef.current = id
const unsubscribe = subscribeToasts(force)
force()
return () => {
release()
unsubscribe()
}
}, [])
const toast = getToast()
if (typeof document === 'undefined' || !isPrimaryHost(idRef.current) || !toast) return null
return createPortal(
<div className="toast-host" aria-live="polite">
<div key={toast.id} className={motionClass(`toast toast-${toast.kind}`, 'toast-in')} role="status">
{toast.text}
</div>
</div>,
document.body,
)
}