{
  "name": "announcer",
  "type": "registry:ui",
  "title": "Announcer + Toast",
  "description": "G5 embodied: announce() renders the one quiet transient line per surface region (Undo lives in the line); the toast viewport is for async outcomes only, max two.",
  "dependencies": [
    "@candlelight-ds/tokens",
    "zustand",
    "lucide-react",
    "clsx",
    "tailwind-merge"
  ],
  "files": [
    {
      "path": "components/ui/Announcer.tsx",
      "type": "registry:ui",
      "content": "'use client'\n\n/**\n * Announcer — one transient line per surface region (grammar G5).\n * State changes the user just made get a quiet confirmation here;\n * toasts are reserved for async outcomes. Undo lives in the line,\n * not a button row.\n *\n * Use when: confirming a direct manipulation (\"Struck *X*. Undo ⌘Z\").\n * Don't use when: reporting async results or errors (Toast).\n */\n\nimport { cn } from '@/lib/utils'\n\nexport interface Announcement {\n  text: string\n  /** Timestamp — keys the animation so repeat announcements re-fire. */\n  at: number\n  action?: { label: string; onAction: () => void }\n}\n\ninterface AnnouncerProps {\n  announcement: Announcement | null\n  className?: string\n}\n\nexport function Announcer({ announcement, className }: AnnouncerProps) {\n  if (!announcement) return null\n\n  return (\n    <div\n      key={announcement.at}\n      aria-live=\"polite\"\n      className={cn(\n        'pointer-events-none absolute -top-9 left-1/2 z-announce -translate-x-1/2 animate-stage-rise',\n        className\n      )}\n    >\n      <span className=\"flex items-center gap-2 rounded-full border border-hairline bg-surface-2/95 px-4 py-1.5 text-meta text-ink-mute shadow-float\">\n        {announcement.text}\n        {announcement.action ? (\n          <button\n            type=\"button\"\n            onClick={announcement.action.onAction}\n            className=\"pointer-events-auto font-medium text-brass transition-colors hover:text-brass-hot\"\n          >\n            {announcement.action.label}\n          </button>\n        ) : null}\n      </span>\n    </div>\n  )\n}\n",
      "target": "components/ui/Announcer.tsx"
    },
    {
      "path": "components/ui/Toast.tsx",
      "type": "registry:ui",
      "content": "'use client'\n\n/**\n * Toast — async outcomes ONLY (grammar G5). Direct manipulation is\n * confirmed by the change itself plus the Announcer's one quiet line;\n * toasts exist for results that arrive later (upload finished, sync\n * failed). Max two on screen; every destructive outcome carries Undo.\n *\n * Use when: an async job finishes or fails while the user is elsewhere.\n * Don't use when: confirming something the user just did (Announcer).\n */\n\nimport { create } from 'zustand'\nimport { X } from 'lucide-react'\n// The law number lives in ONE home (spec §8) — import, never re-declare.\nimport { TOAST_WALL_MS } from '@candlelight-ds/tokens/metrics'\nimport { cn } from '@/lib/utils'\n\nexport type ToastTone = 'ready' | 'danger' | 'warn' | 'info'\n\nexport interface ToastEntry {\n  id: string\n  message: string\n  tone: ToastTone\n  /** Undo (or retry) — required for destructive outcomes (G4/G5). */\n  action?: { label: string; onAction: () => void }\n}\n\ninterface ToastState {\n  toasts: ToastEntry[]\n  toast: (message: string, tone?: ToastTone, action?: ToastEntry['action']) => void\n  dismiss: (id: string) => void\n}\n\n/** G5: max two toasts; the oldest yields. */\nconst MAX_TOASTS = 2\n\nexport const useToastStore = create<ToastState>()((set) => ({\n  toasts: [],\n  toast: (message, tone = 'info', action) => {\n    const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`\n    set((state) => ({\n      toasts: [...state.toasts, { id, message, tone, action }].slice(-MAX_TOASTS),\n    }))\n    setTimeout(() => {\n      set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) }))\n    }, TOAST_WALL_MS)\n  },\n  dismiss: (id) =>\n    set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) })),\n}))\n\n/** Imperative entry: toast(\"Upload finished\", \"ready\"). */\nexport const toast = (\n  message: string,\n  tone: ToastTone = 'info',\n  action?: ToastEntry['action']\n) => useToastStore.getState().toast(message, tone, action)\n\nconst DOT_TONE: Record<ToastTone, string> = {\n  ready: 'bg-ready',\n  danger: 'bg-danger',\n  warn: 'bg-warn',\n  info: 'bg-ink-faint',\n}\n\n/** Mount once, in the app shell (the console anchors it in practice). */\nexport function ToastViewport({\n  className,\n  /** Copy (§9) — the dismiss verb reaches your copy module. */\n  dismissLabel = 'Dismiss',\n}: {\n  className?: string\n  dismissLabel?: string\n}) {\n  const { toasts, dismiss } = useToastStore()\n\n  if (toasts.length === 0) return null\n\n  return (\n    <div\n      className={cn(\n        'fixed bottom-4 end-4 z-announce flex max-w-sm flex-col gap-2',\n        className\n      )}\n    >\n      {toasts.map((entry) => (\n        <div\n          key={entry.id}\n          role=\"alert\"\n          aria-live=\"polite\"\n          className=\"flex min-w-[300px] items-start gap-3 rounded-sm border border-hairline bg-surface-2 p-3.5 shadow-float animate-stage-rise\"\n        >\n          <span\n            className={cn('mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full', DOT_TONE[entry.tone])}\n          />\n          <div className=\"flex-1 text-body leading-5 text-ink\">\n            {entry.message}\n            {entry.action ? (\n              <button\n                type=\"button\"\n                onClick={() => {\n                  entry.action?.onAction()\n                  dismiss(entry.id)\n                }}\n                className=\"ms-2 font-medium text-brass transition-colors hover:text-brass-hot\"\n              >\n                {entry.action.label}\n              </button>\n            ) : null}\n          </div>\n          <button\n            type=\"button\"\n            onClick={() => dismiss(entry.id)}\n            aria-label={dismissLabel}\n            className=\"flex h-6 w-6 shrink-0 items-center justify-center rounded-cap text-ink-faint transition-colors hover:bg-fill-hover hover:text-ink\"\n          >\n            <X className=\"h-3.5 w-3.5\" />\n          </button>\n        </div>\n      ))}\n    </div>\n  )\n}\n",
      "target": "components/ui/Toast.tsx"
    },
    {
      "path": "hooks/useAnnouncer.ts",
      "type": "registry:hook",
      "content": "'use client'\n\n/**\n * useAnnouncer — the one transient line (G5). One announcement at a\n * time, 5.5s on the wall, destructive announcements carry their Undo.\n * The same grammar every surface speaks.\n */\n\nimport { useCallback, useEffect, useState } from 'react'\n// The law number lives in ONE home (spec §8) — import, never re-declare.\nimport { ANNOUNCER_WALL_MS } from '@candlelight-ds/tokens/metrics'\nimport type { Announcement } from '@/components/ui/Announcer'\n\nexport function useAnnouncer() {\n  const [announcement, setAnnouncement] = useState<Announcement | null>(null)\n\n  const announce = useCallback((text: string, action?: Announcement['action']) => {\n    setAnnouncement({ text, at: Date.now(), action })\n  }, [])\n\n  const clearAnnouncement = useCallback(() => {\n    setAnnouncement(null)\n  }, [])\n\n  useEffect(() => {\n    if (!announcement) return\n    const timer = setTimeout(() => setAnnouncement(null), ANNOUNCER_WALL_MS)\n    return () => clearTimeout(timer)\n  }, [announcement])\n\n  return { announcement, announce, clearAnnouncement }\n}\n",
      "target": "hooks/useAnnouncer.ts"
    }
  ],
  "meta": {
    "candlelight": {
      "category": "primitive",
      "gRules": [
        "G4",
        "G5"
      ],
      "useWhen": "Announcer: confirming a direct manipulation. Toast: an async job finished or failed while the user was elsewhere.",
      "dontUseWhen": "Celebration toasts for direct actions — the change itself is the confirmation.",
      "states": [
        "announcement visible (5.5s wall)",
        "announcement with Undo",
        "toast ready/danger/warn/info",
        "two toasts (the cap)"
      ],
      "keyboard": [],
      "acceptanceTests": [
        "One announcement at a time, in a fixed spot, in voice.",
        "Max two toasts; the oldest yields.",
        "Every destructive outcome carries Undo."
      ]
    }
  },
  "registryDependencies": [
    "utils"
  ]
}