{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "draft-review",
  "type": "registry:component",
  "title": "Draft Review",
  "description": "An email the assistant wrote for you to check before it goes: change who it's to or how it sounds, tweak the words, and send with a few seconds to take it back.",
  "categories": [
    "component",
    "Writing"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/button.json",
    "https://coss.com/ui/r/segmented-control.json"
  ],
  "files": [
    {
      "path": "registry/devl/draft-review.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport {\n  CheckIcon,\n  ChevronDownIcon,\n  FileTextIcon,\n  PaperclipIcon,\n  PencilIcon,\n  SendIcon,\n  SparklesIcon,\n  Trash2Icon,\n  Undo2Icon,\n  XIcon,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type KeyboardEvent, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { SegmentedControl, SegmentedControlItem } from \"@/components/ui/segmented-control\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface EmailRecipient {\n  name: string;\n  email: string;\n  firstName?: string;\n}\n\nexport interface EmailAttachment {\n  name: string;\n  size: string;\n}\n\nexport interface DraftReviewProps<T extends string = string> {\n  recipients: EmailRecipient[];\n  subject: string;\n  bodies: Record<T, string>;\n  attachment?: EmailAttachment;\n  undoMs?: number;\n  onSend?: (draft: { to: EmailRecipient; subject: string; body: string; attachment?: EmailAttachment }) => void;\n  className?: string;\n}\n\ntype Phase = \"draft\" | \"sent\" | \"discarded\";\n\nconst spring = { type: \"spring\", stiffness: 420, damping: 34 } as const;\nconst easeOut = [0.23, 1, 0.32, 1] as const;\nconst press = \"transition-[scale,background-color,box-shadow] duration-150 ease-out active:scale-[0.97]\";\n\nfunction fill(template: string, r: EmailRecipient) {\n  return template.replaceAll(\"{name}\", r.firstName ?? r.name.split(\" \")[0]);\n}\n\nfunction ToneSwitch<T extends string>({ tones, value, onChange }: { tones: T[]; value: T; onChange: (t: T) => void }) {\n  const refs = useRef<(HTMLButtonElement | null)[]>([]);\n  const index = tones.indexOf(value);\n\n  const onKeyDown = (e: KeyboardEvent) => {\n    if (e.key !== \"ArrowRight\" && e.key !== \"ArrowLeft\") return;\n    e.preventDefault();\n    const next = (index + (e.key === \"ArrowRight\" ? 1 : -1) + tones.length) % tones.length;\n    onChange(tones[next]);\n    refs.current[next]?.focus();\n  };\n\n  return (\n    <SegmentedControl aria-label=\"Tone\" onKeyDown={onKeyDown} role=\"radiogroup\" size=\"sm\">\n      {tones.map((t, i) => {\n        const active = t === value;\n        return (\n          <SegmentedControlItem\n            aria-checked={active}\n            className=\"px-2.5 text-muted-foreground text-xs capitalize focus-visible:ring-2 focus-visible:ring-ring sm:text-xs\"\n            data-checked={active ? \"\" : undefined}\n            key={t}\n            onClick={() => onChange(t)}\n            ref={(el: HTMLButtonElement | null) => {\n              refs.current[i] = el;\n            }}\n            role=\"radio\"\n            tabIndex={active ? 0 : -1}\n          >\n            {t}\n          </SegmentedControlItem>\n        );\n      })}\n    </SegmentedControl>\n  );\n}\n\nfunction RecipientPicker({\n  recipients,\n  value,\n  onChange,\n  disabled,\n}: {\n  recipients: EmailRecipient[];\n  value: number;\n  onChange: (i: number) => void;\n  disabled?: boolean;\n}) {\n  const reduce = useReducedMotion();\n  const [open, setOpen] = useState(false);\n  const [hi, setHi] = useState(value);\n  const listId = useId();\n  const current = recipients[value];\n\n  const pick = (i: number) => {\n    onChange(i);\n    setOpen(false);\n  };\n\n  const onKeyDown = (e: KeyboardEvent) => {\n    if (e.key === \"Escape\") return setOpen(false);\n    if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n      e.preventDefault();\n      if (!open) {\n        setHi(value);\n        return setOpen(true);\n      }\n      setHi((h) => (h + (e.key === \"ArrowDown\" ? 1 : -1) + recipients.length) % recipients.length);\n    }\n    if (open && e.key === \"Enter\") {\n      e.preventDefault();\n      pick(hi);\n    }\n  };\n\n  return (\n    <div\n      className=\"relative\"\n      onBlur={(e) => {\n        if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setOpen(false);\n      }}\n    >\n      <button\n        aria-activedescendant={open ? `${listId}-${hi}` : undefined}\n        aria-controls={listId}\n        aria-expanded={open}\n        aria-haspopup=\"listbox\"\n        className=\"relative flex h-7 max-w-full cursor-pointer items-center gap-1.5 rounded-full border bg-background ps-1 pe-2 text-sm outline-none transition-[background-color,scale] duration-150 ease-out after:absolute after:-inset-y-1 after:inset-x-0 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97] disabled:pointer-events-none dark:bg-input/32\"\n        disabled={disabled}\n        onClick={() => {\n          setHi(value);\n          setOpen((o) => !o);\n        }}\n        onKeyDown={onKeyDown}\n        type=\"button\"\n      >\n        <span className=\"flex size-5 shrink-0 items-center justify-center rounded-full bg-info/14 font-medium text-[10px] text-info-foreground\">\n          {current.name\n            .split(\" \")\n            .map((p) => p[0])\n            .slice(0, 2)\n            .join(\"\")}\n        </span>\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            animate={{ opacity: 1, y: 0 }}\n            className=\"truncate font-medium\"\n            exit={{ opacity: 0, y: -6 }}\n            initial={reduce ? false : { opacity: 0, y: 6 }}\n            key={current.email}\n            transition={spring}\n          >\n            {current.name}\n          </motion.span>\n        </AnimatePresence>\n        <ChevronDownIcon className={cn(\"size-3.5 shrink-0 text-muted-foreground transition-transform\", open && \"rotate-180\")} />\n      </button>\n      <AnimatePresence>\n        {open && (\n          <motion.ul\n            animate={{ opacity: 1, scale: 1, y: 0 }}\n            className=\"absolute top-full left-0 z-20 mt-1 w-64 rounded-xl border bg-popover p-1 text-popover-foreground shadow-md/5\"\n            exit={{ opacity: 0, scale: 0.97, y: -4, transition: { duration: 0.12, ease: easeOut } }}\n            id={listId}\n            initial={reduce ? false : { opacity: 0, scale: 0.97, y: -4 }}\n            role=\"listbox\"\n            style={{ transformOrigin: \"top left\" }}\n            transition={spring}\n          >\n            {recipients.map((r, i) => (\n              <li\n                aria-selected={i === value}\n                className={cn(\n                  \"flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-sm\",\n                  i === hi && \"bg-accent\",\n                )}\n                id={`${listId}-${i}`}\n                key={r.email}\n                onMouseDown={(e) => {\n                  e.preventDefault();\n                  pick(i);\n                }}\n                onMouseEnter={() => setHi(i)}\n                role=\"option\"\n              >\n                <span className=\"min-w-0 flex-1\">\n                  <span className=\"block truncate font-medium\">{r.name}</span>\n                  <span className=\"block truncate text-muted-foreground text-xs\">{r.email}</span>\n                </span>\n                {i === value && <CheckIcon className=\"size-4 text-muted-foreground\" />}\n              </li>\n            ))}\n          </motion.ul>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nexport function DraftReview<T extends string>({\n  recipients,\n  subject: initialSubject,\n  bodies,\n  attachment: initialAttachment,\n  undoMs = 5000,\n  onSend,\n  className,\n}: DraftReviewProps<T>) {\n  const reduce = useReducedMotion();\n  const tones = Object.keys(bodies) as T[];\n  const [to, setTo] = useState(0);\n  const [tone, setTone] = useState<T>(tones[0]);\n  const [subject, setSubject] = useState(initialSubject);\n  const [custom, setCustom] = useState<string | null>(null);\n  const [editing, setEditing] = useState(false);\n  const [rewriting, setRewriting] = useState(false);\n  const [attachment, setAttachment] = useState(initialAttachment);\n  const [phase, setPhase] = useState<Phase>(\"draft\");\n  const [toast, setToast] = useState<null | { kind: \"sent\" | \"discarded\"; key: number }>(null);\n  const textRef = useRef<HTMLTextAreaElement>(null);\n  const innerRef = useRef<HTMLDivElement>(null);\n  const [height, setHeight] = useState<number | \"auto\">(\"auto\");\n\n  useLayoutEffect(() => {\n    const el = innerRef.current;\n    if (!el) return;\n    const ro = new ResizeObserver(() => setHeight(el.offsetHeight));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n  const subjectId = useId();\n\n  const generated = fill(bodies[tone], recipients[to]);\n  const body = custom ?? generated;\n\n  const first = useRef(true);\n  useEffect(() => {\n    if (first.current) {\n      first.current = false;\n      return;\n    }\n    if (reduce) return;\n    setRewriting(true);\n    const t = setTimeout(() => setRewriting(false), 320);\n    return () => clearTimeout(t);\n  }, [tone, to, reduce]);\n\n  useEffect(() => {\n    if (!toast) return;\n    const t = setTimeout(() => setToast(null), undoMs);\n    return () => clearTimeout(t);\n  }, [toast, undoMs]);\n\n  useEffect(() => {\n    const el = textRef.current;\n    if (!editing || !el) return;\n    el.focus();\n    el.setSelectionRange(el.value.length, el.value.length);\n  }, [editing]);\n\n  const rewrite = (fn: () => void) => {\n    setCustom(null);\n    setEditing(false);\n    fn();\n  };\n\n  const finish = (kind: \"sent\" | \"discarded\") => {\n    setEditing(false);\n    setPhase(kind);\n    setToast({ kind, key: Date.now() });\n    if (kind === \"sent\") onSend?.({ to: recipients[to], subject, body, attachment });\n  };\n\n  const undo = () => {\n    setPhase(\"draft\");\n    setToast(null);\n  };\n\n  const reset = () => {\n    setPhase(\"draft\");\n    setToast(null);\n    setCustom(null);\n    setSubject(initialSubject);\n    setAttachment(initialAttachment);\n  };\n\n  return (\n    <section\n      aria-label=\"Email draft\"\n      className={cn(\n        \"relative w-full max-w-[460px] overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-xs/5\",\n        className,\n      )}\n    >\n      <header className=\"flex items-center gap-2 border-b px-4 py-2.5 text-xs\">\n        <span className=\"flex size-5 items-center justify-center rounded-full bg-primary text-primary-foreground\">\n          <SparklesIcon className=\"size-3\" />\n        </span>\n        <span className=\"font-medium\">Draft ready for review</span>\n        <AnimatePresence>\n          {custom !== null && phase === \"draft\" && (\n            <motion.span\n              animate={{ opacity: 1, scale: 1 }}\n              className=\"rounded-full bg-warning/12 px-1.5 py-px font-medium text-[10px] text-warning-foreground\"\n              exit={{ opacity: 0, scale: 0.8 }}\n              initial={reduce ? false : { opacity: 0, scale: 0.8 }}\n              transition={spring}\n            >\n              Edited\n            </motion.span>\n          )}\n        </AnimatePresence>\n        <span className=\"ms-auto text-muted-foreground\">\n          {phase === \"draft\" ? \"Not sent\" : phase === \"sent\" ? \"Sent\" : \"Discarded\"}\n        </span>\n      </header>\n\n      <motion.div\n        animate={{ height }}\n        className=\"overflow-hidden\"\n        initial={false}\n        transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 300, damping: 32 }}\n      >\n      <div ref={innerRef}>\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {phase === \"draft\" ? (\n          <motion.div\n            animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97, filter: \"blur(4px)\" }}\n            initial={reduce ? false : { opacity: 0, scale: 0.98, filter: \"blur(4px)\" }}\n            key=\"draft\"\n            transition={spring}\n          >\n            <div className=\"space-y-2.5 px-4 pt-3\">\n              <div className=\"flex items-center gap-3 text-sm\">\n                <span className=\"w-12 shrink-0 text-muted-foreground text-xs\">To</span>\n                <RecipientPicker onChange={(i) => rewrite(() => setTo(i))} recipients={recipients} value={to} />\n              </div>\n              <div className=\"flex items-center gap-3 text-sm\">\n                <label className=\"w-12 shrink-0 text-muted-foreground text-xs\" htmlFor={subjectId}>\n                  Subject\n                </label>\n                <input\n                  className=\"-mx-1.5 h-7 min-w-0 flex-1 rounded-md bg-transparent px-1.5 font-medium text-sm outline-none transition-colors hover:bg-accent/40 focus-visible:bg-accent/60\"\n                  id={subjectId}\n                  onChange={(e) => setSubject(e.target.value)}\n                  value={subject}\n                />\n              </div>\n              <div className=\"flex items-center gap-3 border-t pt-2.5\">\n                <span className=\"w-12 shrink-0 text-muted-foreground text-xs\">Tone</span>\n                <ToneSwitch onChange={(t) => rewrite(() => setTone(t))} tones={tones} value={tone} />\n              </div>\n            </div>\n\n            <div className=\"group/body relative mx-4 mt-3 min-h-[148px] rounded-xl bg-muted/40 p-3\">\n              {editing ? (\n                <textarea\n                  aria-label=\"Email body\"\n                  className=\"field-sizing-content block min-h-[124px] w-full resize-none whitespace-pre-wrap bg-transparent p-0 text-sm leading-relaxed outline-none\"\n                  onBlur={() => setEditing(false)}\n                  onChange={(e) => setCustom(e.target.value)}\n                  onKeyDown={(e) => {\n                    if (e.key === \"Escape\") setEditing(false);\n                  }}\n                  ref={textRef}\n                  value={body}\n                />\n              ) : (\n                <AnimatePresence initial={false} mode=\"popLayout\">\n                  {rewriting ? (\n                    <motion.div\n                      animate={{ opacity: 1 }}\n                      aria-label=\"Rewriting\"\n                      className=\"space-y-2 pt-1\"\n                      exit={{ opacity: 0 }}\n                      initial={{ opacity: 0 }}\n                      key=\"rewriting\"\n                      role=\"status\"\n                    >\n                      {[92, 100, 78, 64].map((w, i) => (\n                        <motion.span\n                          animate={{ opacity: [0.4, 0.9, 0.4] }}\n                          className=\"block h-2.5 rounded-full bg-foreground/10\"\n                          key={i}\n                          style={{ width: `${w}%` }}\n                          transition={{ duration: 0.9, repeat: Number.POSITIVE_INFINITY, delay: i * 0.08 }}\n                        />\n                      ))}\n                    </motion.div>\n                  ) : (\n                    <motion.button\n                      animate={{ opacity: 1, filter: \"blur(0px)\", y: 0 }}\n                      aria-label=\"Edit email body\"\n                      className=\"-m-1 block w-[calc(100%+--spacing(2))] cursor-text whitespace-pre-wrap rounded-lg p-1 text-left text-sm leading-relaxed outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n                      exit={{ opacity: 0, filter: \"blur(4px)\", transition: { duration: 0.15 } }}\n                      initial={reduce ? false : { opacity: 0, filter: \"blur(4px)\", y: 4 }}\n                      key={`${tone}-${to}`}\n                      onClick={() => setEditing(true)}\n                      transition={{ duration: 0.25, ease: easeOut }}\n                      type=\"button\"\n                    >\n                      {body}\n                    </motion.button>\n                  )}\n                </AnimatePresence>\n              )}\n              {!editing && !rewriting && (\n                <span className=\"pointer-events-none absolute top-2 right-2 flex items-center gap-1 rounded-full bg-card px-1.5 py-0.5 text-[10px] text-muted-foreground opacity-0 shadow-xs/5 transition-opacity duration-150 group-hover/body:opacity-100 group-has-focus-visible/body:opacity-100\">\n                  <PencilIcon className=\"size-2.5\" /> Click to edit\n                </span>\n              )}\n            </div>\n\n            <div className=\"flex min-h-11 items-center px-4 pt-2\">\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                {attachment ? (\n                  <motion.div\n                    animate={{ opacity: 1, scale: 1 }}\n                    className=\"flex items-center gap-2 rounded-lg border bg-background py-1 ps-1 pe-1 text-xs dark:bg-input/32\"\n                    exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9, x: -8, transition: { duration: 0.15, ease: easeOut } }}\n                    initial={reduce ? false : { opacity: 0, scale: 0.95 }}\n                    key=\"file\"\n                    transition={spring}\n                  >\n                    <span className=\"flex size-6 items-center justify-center rounded-md bg-destructive/10 text-destructive-foreground\">\n                      <FileTextIcon className=\"size-3.5\" />\n                    </span>\n                    <span className=\"font-medium\">{attachment.name}</span>\n                    <span className=\"text-muted-foreground tabular-nums\">{attachment.size}</span>\n                    <button\n                      aria-label={`Remove ${attachment.name}`}\n                      className=\"relative flex size-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none transition-[background-color,color,scale] duration-150 ease-out after:absolute after:-inset-1 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97]\"\n                      onClick={() => setAttachment(undefined)}\n                      type=\"button\"\n                    >\n                      <XIcon className=\"size-3\" />\n                    </button>\n                  </motion.div>\n                ) : (\n                  <motion.span\n                    animate={{ opacity: 1 }}\n                    className=\"flex items-center gap-1.5 text-muted-foreground text-xs\"\n                    initial={reduce ? false : { opacity: 0 }}\n                    key=\"none\"\n                  >\n                    <PaperclipIcon className=\"size-3.5\" /> No attachment\n                  </motion.span>\n                )}\n              </AnimatePresence>\n            </div>\n\n            <footer className=\"flex items-center justify-end gap-2 px-4 pt-2 pb-4\">\n              <Button className={press} onClick={() => finish(\"discarded\")} size=\"sm\" variant=\"ghost\">\n                <Trash2Icon />\n                Discard\n              </Button>\n              <Button className={press} onClick={() => finish(\"sent\")} size=\"sm\">\n                <SendIcon />\n                Send\n              </Button>\n            </footer>\n          </motion.div>\n        ) : (\n          <motion.div\n            animate={{ opacity: 1, scale: 1 }}\n            className={cn(\n              \"flex flex-col items-center gap-2 px-6 pt-8 text-center transition-[padding] duration-200 ease-out\",\n              toast ? \"pb-[72px]\" : \"pb-6\",\n            )}\n            initial={reduce ? false : { opacity: 0, scale: 0.96 }}\n            key=\"done\"\n            transition={spring}\n          >\n            <motion.span\n              animate={{ scale: 1, rotate: 0 }}\n              className={cn(\n                \"flex size-11 items-center justify-center rounded-full\",\n                phase === \"sent\" ? \"bg-success/12 text-success-foreground\" : \"bg-muted text-muted-foreground\",\n              )}\n              initial={reduce ? false : { scale: 0.7, rotate: -20 }}\n              transition={{ type: \"spring\", stiffness: 420, damping: 22 }}\n            >\n              {phase === \"sent\" ? <SendIcon className=\"size-5\" /> : <Trash2Icon className=\"size-5\" />}\n            </motion.span>\n            <p className=\"font-medium text-sm\">\n              {phase === \"sent\" ? `Sent to ${recipients[to].name}` : \"Draft discarded\"}\n            </p>\n            <p className=\"max-w-[28ch] text-muted-foreground text-xs\">\n              {phase === \"sent\" ? subject : toast ? \"Nothing was sent. You can bring it back for a few seconds.\" : \"Nothing was sent.\"}\n            </p>\n            {!toast && (\n              <motion.div animate={{ opacity: 1 }} initial={reduce ? false : { opacity: 0 }}>\n                <Button className={cn(\"mt-2\", press)} onClick={reset} size=\"sm\" variant=\"outline\">\n                  New draft\n                </Button>\n              </motion.div>\n            )}\n          </motion.div>\n        )}\n      </AnimatePresence>\n      </div>\n      </motion.div>\n\n      <AnimatePresence>\n        {toast && (\n          <motion.div\n            animate={{ y: 0, opacity: 1, scale: 1 }}\n            className=\"absolute inset-x-1.5 bottom-1.5 flex items-center gap-3 overflow-hidden rounded-lg bg-primary py-1.5 ps-3 pe-1.5 text-primary-foreground text-sm shadow-md/10\"\n            exit={reduce ? { opacity: 0 } : { y: 24, opacity: 0, scale: 0.96 }}\n            initial={reduce ? false : { y: 24, opacity: 0, scale: 0.96 }}\n            key={toast.key}\n            role=\"status\"\n            transition={{ type: \"spring\", stiffness: 460, damping: 30 }}\n          >\n            {toast.kind === \"sent\" ? (\n              <CheckIcon className=\"size-4 shrink-0 opacity-80\" />\n            ) : (\n              <Trash2Icon className=\"size-4 shrink-0 opacity-80\" />\n            )}\n            <span className=\"min-w-0 flex-1 truncate\">{toast.kind === \"sent\" ? \"Email sent\" : \"Draft discarded\"}</span>\n            <button\n              className=\"relative flex h-7 cursor-pointer items-center gap-1.5 rounded-md px-2 font-medium text-xs outline-none transition-[background-color,scale] duration-150 ease-out after:absolute after:-inset-y-0.5 after:inset-x-0 hover:bg-primary-foreground/12 focus-visible:ring-2 focus-visible:ring-primary-foreground/50 active:scale-[0.97]\"\n              onClick={undo}\n              type=\"button\"\n            >\n              <Undo2Icon className=\"size-3.5\" />\n              Undo\n            </button>\n            <motion.span\n              animate={{ scaleX: 0 }}\n              aria-hidden\n              className=\"absolute inset-x-0 bottom-0 h-0.5 origin-left bg-primary-foreground/40\"\n              initial={{ scaleX: 1 }}\n              transition={{ duration: undoMs / 1000, ease: \"linear\" }}\n            />\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </section>\n  );\n}\n"
    }
  ]
}