{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ghostwriters",
  "type": "registry:component",
  "title": "Ghostwriters",
  "description": "A shared draft with a few ghostwriters in it: each agent's cursor glides in with its name, picks a phrase and types its suggestion right there in the text, and you keep or toss each one, or let them settle on their own.",
  "categories": [
    "component",
    "Together"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/badge.json",
    "https://coss.com/ui/r/button.json"
  ],
  "files": [
    {
      "path": "registry/devl/ghostwriters.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { CheckIcon, XIcon } from \"lucide-react\";\nimport { AnimatePresence, MotionConfig, motion } from \"motion/react\";\nimport { Fragment, type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\nexport type GhostTone = \"info\" | \"brand\" | \"destructive\";\n\nexport interface Ghostwriter {\n  id: string;\n  name: string;\n  tone: GhostTone;\n  role?: string;\n}\n\nexport type DocPiece = string | { id: string; text: string };\n\nexport interface GhostSuggestion {\n  id: string;\n  agent: string;\n  target: string;\n  mode: \"replace\" | \"after\";\n  text: string;\n  from: string;\n  note?: string;\n  at: number;\n}\n\nexport interface GhostwritersProps {\n  title?: string;\n  agents?: Ghostwriter[];\n  paragraphs?: DocPiece[][];\n  suggestions?: GhostSuggestion[];\n  autoplay?: boolean;\n  className?: string;\n}\n\ntype Decision = \"accepted\" | \"rejected\";\n\nconst tones: Record<GhostTone, { solid: string; chip: string; soft: string; mark: string; line: string }> = {\n  info: { solid: \"bg-info\", chip: \"bg-blue-600 text-white\", soft: \"bg-info/14\", mark: \"bg-info/22\", line: \"decoration-info\" },\n  brand: { solid: \"bg-brand\", chip: \"bg-brand text-brand-foreground\", soft: \"bg-brand/30\", mark: \"bg-brand/45\", line: \"decoration-brand-ink\" },\n  destructive: { solid: \"bg-destructive\", chip: \"bg-red-600 text-white\", soft: \"bg-destructive/12\", mark: \"bg-destructive/20\", line: \"decoration-destructive\" },\n};\n\nconst defaultAgents: Ghostwriter[] = [\n  { id: \"editor\", name: \"Editor\", tone: \"info\", role: \"Tightens loose wording\" },\n  { id: \"facts\", name: \"Fact-check\", tone: \"destructive\", role: \"Checks every number\" },\n  { id: \"scout\", name: \"Scout\", tone: \"brand\", role: \"Fills in what readers will ask\" },\n];\n\nconst defaultParagraphs: DocPiece[][] = [\n  [\n    { id: \"h1\", text: \"Halcyon\" },\n    \" 3.2 makes exports \",\n    { id: \"fast\", text: \"really fast\" },\n    \". Beta teams said scheduled reports saved them \",\n    { id: \"pct\", text: \"12%\" },\n    \" of their \",\n    { id: \"h2\", text: \"week\" },\n    \".\",\n  ],\n  [\n    \"Admins also get an \",\n    { id: \"h3\", text: \"audit log\" },\n    \" that keeps every change for \",\n    { id: \"tail\", text: \"90 days.\" },\n  ],\n];\n\nconst defaultSuggestions: GhostSuggestion[] = [\n  { id: \"s1\", agent: \"editor\", target: \"fast\", mode: \"replace\", text: \"up to 4× faster\", from: \"h1\", at: 300 },\n  { id: \"s2\", agent: \"facts\", target: \"pct\", mode: \"replace\", text: \"9%\", from: \"h2\", note: \"beta survey, n=48\", at: 1900 },\n  { id: \"s3\", agent: \"scout\", target: \"tail\", mode: \"after\", text: \" It’s on for every workspace.\", from: \"h3\", at: 3500 },\n];\n\nconst press = \"transition-[box-shadow,scale] duration-150 ease-out active:scale-[0.97]\";\nconst APPEAR = 500;\nconst MOVE = 700;\nconst SELECT = 850;\nconst CHAR = 42;\n\ntype Phase = \"hidden\" | \"home\" | \"moving\" | \"selecting\" | \"typing\" | \"pending\";\n\nfunction phaseOf(s: GhostSuggestion, e: number): { phase: Phase; typed: number } {\n  const t0 = s.at;\n  if (e < t0) return { phase: \"hidden\", typed: 0 };\n  if (e < t0 + APPEAR) return { phase: \"home\", typed: 0 };\n  if (e < t0 + APPEAR + MOVE) return { phase: \"moving\", typed: 0 };\n  const sel = s.mode === \"replace\" ? SELECT : 300;\n  if (e < t0 + APPEAR + MOVE + sel) return { phase: \"selecting\", typed: 0 };\n  const typeStart = t0 + APPEAR + MOVE + sel;\n  const typed = Math.floor((e - typeStart) / CHAR);\n  if (typed < s.text.length) return { phase: \"typing\", typed: Math.max(0, typed) };\n  return { phase: \"pending\", typed: s.text.length };\n}\n\nfunction endOf(s: GhostSuggestion) {\n  return s.at + APPEAR + MOVE + (s.mode === \"replace\" ? SELECT : 300) + s.text.length * CHAR;\n}\n\nexport function Ghostwriters({\n  title = \"Halcyon 3.2 launch notes\",\n  agents = defaultAgents,\n  paragraphs = defaultParagraphs,\n  suggestions = defaultSuggestions,\n  autoplay = true,\n  className,\n}: GhostwritersProps) {\n  const [elapsed, setElapsed] = useState(0);\n  const [decisions, setDecisions] = useState<Record<string, Decision>>({});\n  const [focus, setFocus] = useState<string | null>(null);\n  const [positions, setPositions] = useState<Record<string, { x: number; y: number; h: number }>>({});\n  const docRef = useRef<HTMLDivElement>(null);\n  const lastInput = useRef(0);\n  const doneAt = useRef<number | null>(null);\n  const clock = useRef(0);\n\n  const agentById = useCallback((id: string) => agents.find((a) => a.id === id) ?? agents[0], [agents]);\n  const texts = new Map<string, string>();\n  for (const p of paragraphs) for (const piece of p) if (typeof piece !== \"string\") texts.set(piece.id, piece.text);\n\n  const scriptEnd = Math.max(...suggestions.map(endOf));\n\n  useEffect(() => {\n    let last = performance.now();\n    const id = window.setInterval(() => {\n      const now = performance.now();\n      clock.current += now - last;\n      last = now;\n      setElapsed(clock.current);\n    }, 40);\n    return () => window.clearInterval(id);\n  }, []);\n\n  useEffect(() => {\n    const all = suggestions.every((s) => decisions[s.id]);\n    if (all) {\n      if (doneAt.current === null) doneAt.current = elapsed;\n      if (elapsed - doneAt.current > 2600) {\n        clock.current = 0;\n        doneAt.current = null;\n        setDecisions({});\n        setElapsed(0);\n      }\n      return;\n    }\n    if (!autoplay) return;\n    const quiet = performance.now() - lastInput.current > 3500;\n    const open = suggestions.filter((s) => !decisions[s.id]);\n    const slot = Math.floor((elapsed - scriptEnd - 3200) / 1500);\n    if (quiet && slot >= 0 && open.length) {\n      const resolvedCount = suggestions.length - open.length;\n      if (slot >= resolvedCount) {\n        const next = open[0];\n        setDecisions((d) => ({ ...d, [next.id]: next.mode === \"after\" ? \"rejected\" : \"accepted\" }));\n      }\n    }\n  }, [elapsed, decisions, suggestions, autoplay, scriptEnd]);\n\n  const states = suggestions.map((s) => ({ s, ...phaseOf(s, elapsed), decision: decisions[s.id] }));\n  const byTarget = new Map(states.map((st) => [st.s.target, st]));\n  const byFrom = new Map<string, typeof states>();\n  for (const st of states) byFrom.set(st.s.from, [...(byFrom.get(st.s.from) ?? []), st]);\n\n  useLayoutEffect(() => {\n    const root = docRef.current;\n    if (!root) return;\n    const box = root.getBoundingClientRect();\n    const next: Record<string, { x: number; y: number; h: number }> = {};\n    for (const el of root.querySelectorAll<HTMLElement>(\"[data-caret]\")) {\n      const r = el.getBoundingClientRect();\n      next[el.dataset.caret as string] = { x: r.left - box.left, y: r.top - box.top, h: r.height };\n    }\n    const same =\n      Object.keys(next).length === Object.keys(positions).length &&\n      Object.entries(next).every(([k, v]) => positions[k] && Math.abs(positions[k].x - v.x) < 0.5 && Math.abs(positions[k].y - v.y) < 0.5);\n    if (!same) setPositions(next);\n  });\n\n  const caret = (agentId: string) => (\n    <span aria-hidden data-caret={agentId}>\n      {\"\\u2060\"}\n    </span>\n  );\n\n  const renderPiece = (piece: DocPiece, key: number): ReactNode => {\n    if (typeof piece === \"string\") return <Fragment key={key}>{piece}</Fragment>;\n    const homes = (byFrom.get(piece.id) ?? []).filter((st) => st.phase === \"home\");\n    const st = byTarget.get(piece.id);\n    const homeCarets = homes.map((h) => <Fragment key={h.s.id}>{caret(h.s.agent)}</Fragment>);\n    if (!st || st.phase === \"hidden\" || st.phase === \"home\") {\n      return (\n        <Fragment key={key}>\n          {homeCarets}\n          {piece.text}\n        </Fragment>\n      );\n    }\n    const t = tones[agentById(st.s.agent).tone];\n    const lit = focus === st.s.id;\n    if (st.decision === \"accepted\") {\n      return (\n        <Fragment key={`${key}-a`}>\n          {homeCarets}\n          {st.s.mode === \"after\" ? piece.text : null}\n          <span className=\"relative\">\n              <motion.span\n                animate={{ opacity: 0 }}\n                className={cn(\"absolute -inset-x-0.5 inset-y-0 rounded-sm\", t.mark)}\n                initial={{ opacity: 1 }}\n                transition={{ duration: 0.9, ease: \"easeOut\" }}\n              />\n            <span className=\"relative\">{st.s.text}</span>\n          </span>\n        </Fragment>\n      );\n    }\n    if (st.decision === \"rejected\") return <Fragment key={`${key}-r`}>{homeCarets}{piece.text}</Fragment>;\n\n    const typing = st.phase === \"typing\" || st.phase === \"pending\";\n    const original =\n      st.phase === \"moving\" ? (\n        <>\n          {caret(st.s.agent)}\n          {piece.text}\n        </>\n      ) : st.phase === \"selecting\" ? (\n        <>\n          <mark className={cn(\"rounded-sm text-foreground\", st.s.mode === \"replace\" ? t.mark : \"bg-transparent\")}>{piece.text}</mark>\n          {caret(st.s.agent)}\n        </>\n      ) : st.s.mode === \"replace\" ? (\n        <del className={cn(\"mr-1 text-muted-foreground decoration-2\", t.line)}>{piece.text}</del>\n      ) : (\n        piece.text\n      );\n    return (\n      <Fragment key={key}>\n        {homeCarets}\n        {original}\n        {typing && (\n          <ins className={cn(\"rounded-sm text-foreground no-underline transition-colors\", lit ? t.mark : t.soft)}>\n            {st.s.text.slice(0, st.typed)}\n            {caret(st.s.agent)}\n          </ins>\n        )}\n      </Fragment>\n    );\n  };\n\n  const spring = { type: \"spring\" as const, stiffness: 320, damping: 30, mass: 0.8 };\n  const fade = { duration: 0.16, ease: [0.23, 1, 0.32, 1] as const };\n  const active = states.filter((st) => st.phase !== \"hidden\" && !st.decision);\n\n  const decide = (id: string, d: Decision) => {\n    lastInput.current = performance.now();\n    setDecisions((prev) => ({ ...prev, [id]: d }));\n    setFocus(null);\n  };\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n    <div\n      className={cn(\"w-full max-w-[460px] rounded-2xl border bg-card text-card-foreground shadow-xs/5\", className)}\n      onPointerMove={() => {\n        lastInput.current = performance.now();\n      }}\n    >\n      <div className=\"flex items-center justify-between gap-3 border-b px-4 py-3\">\n        <div className=\"min-w-0\">\n          <div className=\"truncate font-medium text-sm\">{title}</div>\n          <div className=\"text-muted-foreground text-xs\">\n            {active.length ? `${active.length} ${active.length === 1 ? \"agent\" : \"agents\"} editing` : \"Draft · saved\"}\n          </div>\n        </div>\n        <div className=\"flex -space-x-1\">\n          {agents.map((a) => {\n            const on = active.some((st) => st.s.agent === a.id);\n            const t = tones[a.tone];\n            return (\n              <span\n                className={cn(\n                  \"flex size-6 items-center justify-center rounded-full font-medium text-[10px] ring-2 ring-card transition-colors duration-200 ease-out\",\n                  on ? t.chip : \"bg-[color-mix(in_oklab,var(--color-foreground)_10%,var(--color-card))] text-muted-foreground\",\n                )}\n                key={a.id}\n                title={a.name}\n              >\n                {a.name[0]}\n              </span>\n            );\n          })}\n        </div>\n      </div>\n\n      <div className=\"relative px-4 pt-5 pb-4\" ref={docRef}>\n        {/* An invisible copy with every suggestion written in reserves the height, so nothing below jumps while agents type. */}\n        <div className=\"grid\">\n          <div aria-hidden className=\"invisible space-y-2.5 text-pretty text-sm leading-[30px] [grid-area:1/1]\">\n            {paragraphs.map((p, i) => (\n              <p key={i}>\n                {p.map((piece) => {\n                  if (typeof piece === \"string\") return piece;\n                  const sug = suggestions.find((x) => x.target === piece.id);\n                  return sug ? `${piece.text}${sug.mode === \"replace\" ? \" \" : \"\"}${sug.text}` : piece.text;\n                })}\n              </p>\n            ))}\n          </div>\n          <div className=\"space-y-2.5 text-pretty text-sm leading-[30px] [grid-area:1/1]\">\n            {paragraphs.map((p, i) => (\n              <p key={i}>{p.map(renderPiece)}</p>\n            ))}\n          </div>\n        </div>\n\n        <div aria-hidden className=\"pointer-events-none absolute inset-0\">\n          <AnimatePresence>\n            {active.map((st) => {\n              const pos = positions[st.s.agent];\n              if (!pos) return null;\n              const a = agentById(st.s.agent);\n              const t = tones[a.tone];\n              const h = Math.min(pos.h || 18, 20);\n              const quiet = st.phase === \"pending\" && elapsed > endOf(st.s) + 1400;\n              return (\n                <motion.div\n                  animate={{ x: pos.x - 1, y: pos.y + ((pos.h || h) - h) / 2, opacity: 1 }}\n                  className=\"absolute top-0 left-0\"\n                  exit={{ opacity: 0, transition: fade }}\n                  initial={{ x: pos.x - 1, y: pos.y + ((pos.h || h) - h) / 2, opacity: 0 }}\n                  key={st.s.agent}\n                  transition={spring}\n                >\n                  <span className={cn(\"block w-0.5 rounded-full\", t.solid, st.phase === \"pending\" && \"animate-caret-blink\")} style={{ height: h }} />\n                  <motion.span\n                    animate={quiet ? { opacity: 0, scale: 0.9 } : { opacity: 1, scale: 1 }}\n                    className={cn(\n                      \"absolute bottom-full left-0 mb-px origin-bottom-left whitespace-nowrap rounded-full rounded-bl-[calc(var(--radius)*0.3)] px-1.5 font-medium text-[10px] leading-[14px] shadow-xs/5\",\n                      t.chip,\n                    )}\n                    initial={false}\n                    transition={fade}\n                  >\n                    {a.name}\n                  </motion.span>\n                  <motion.span\n                    animate={{ opacity: quiet ? 1 : 0 }}\n                    className={cn(\"absolute -top-1 -left-[2px] size-1.5 rounded-full\", t.solid)}\n                    initial={false}\n                  />\n                </motion.div>\n              );\n            })}\n          </AnimatePresence>\n        </div>\n      </div>\n\n      <ul aria-label=\"Suggestions\" className=\"border-t px-2 py-2\">\n        {states.length === 0 && <li className=\"flex min-h-11 items-center px-2 text-muted-foreground text-xs\">No suggestions yet. Agents comment here as they read.</li>}\n        {states.map((st) => {\n          const a = agentById(st.s.agent);\n          const t = tones[a.tone];\n          const pending = st.phase === \"pending\" && !st.decision;\n          const writing = st.phase === \"typing\" || st.phase === \"pending\" || st.decision;\n          const status = st.decision\n            ? null\n            : st.phase === \"pending\"\n              ? (st.s.note ?? (st.s.mode === \"after\" ? \"adds a sentence\" : \"suggests a rewrite\"))\n              : st.phase === \"typing\"\n                ? \"typing…\"\n                : st.phase === \"selecting\"\n                  ? \"selecting…\"\n                  : st.phase === \"hidden\"\n                    ? \"waiting\"\n                    : \"reading…\";\n          return (\n            <li\n              className={cn(\"flex min-h-11 items-center gap-2.5 rounded-lg px-2 py-1.5 transition-colors duration-150 ease-out\", focus === st.s.id && pending && \"bg-accent\")}\n              key={st.s.id}\n              onBlur={() => setFocus(null)}\n              onFocus={() => setFocus(st.s.id)}\n              onPointerEnter={(e) => e.pointerType === \"mouse\" && setFocus(st.s.id)}\n              onPointerLeave={() => setFocus(null)}\n            >\n              <span className={cn(\"size-2 shrink-0 rounded-full transition-colors\", writing || st.phase !== \"hidden\" ? t.solid : \"bg-foreground/20\")} />\n              <div className={cn(\"min-w-0 flex-1 text-xs transition-opacity duration-300\", st.decision && \"opacity-60\")}>\n                <div className=\"flex items-baseline gap-1.5\">\n                  <span className=\"font-medium\">{a.name}</span>\n                  {status && <span className=\"truncate text-muted-foreground\">{status}</span>}\n                </div>\n                <AnimatePresence initial={false} mode=\"wait\">\n                  {writing ? (\n                    <motion.div animate={{ opacity: 1, y: 0 }} className=\"truncate\" initial={{ opacity: 0, y: 3 }} key=\"diff\" transition={fade}>\n                      {st.s.mode === \"replace\" && st.decision !== \"accepted\" && (\n                        <>\n                          <span className={cn(st.decision === \"rejected\" ? \"text-foreground\" : \"text-muted-foreground line-through\")}>{texts.get(st.s.target)}</span>\n                          <span className=\"px-1 text-muted-foreground\">→</span>\n                        </>\n                      )}\n                      <span className={cn(st.decision === \"rejected\" && \"text-muted-foreground line-through\")}>\n                        {st.s.mode === \"after\" ? `+ ${st.s.text.slice(0, st.typed).trim()}` : st.s.text.slice(0, st.typed)}\n                      </span>\n                    </motion.div>\n                  ) : (\n                    <motion.div animate={{ opacity: 1 }} className=\"truncate text-muted-foreground\" exit={{ opacity: 0, transition: fade }} initial={{ opacity: 0 }} key=\"role\" transition={fade}>\n                      {a.role ?? \"Reading the draft\"}\n                    </motion.div>\n                  )}\n                </AnimatePresence>\n              </div>\n              <div className=\"flex h-9 w-[76px] shrink-0 items-center justify-end gap-1 sm:h-8 sm:w-[68px]\">\n                <AnimatePresence initial={false} mode=\"popLayout\">\n                  {st.decision ? (\n                    <motion.div animate={{ opacity: 1, scale: 1 }} initial={{ opacity: 0, scale: 0.95 }} key=\"badge\" transition={fade}>\n                      <Badge variant={st.decision === \"accepted\" ? \"success\" : \"secondary\"}>{st.decision === \"accepted\" ? \"Accepted\" : \"Rejected\"}</Badge>\n                    </motion.div>\n                  ) : pending ? (\n                    <motion.div animate={{ opacity: 1, scale: 1 }} className=\"flex gap-1\" exit={{ opacity: 0, scale: 0.95 }} initial={{ opacity: 0, scale: 0.95 }} key=\"actions\" transition={fade}>\n                      <Button aria-label={`Reject ${a.name}'s suggestion`} onClick={() => decide(st.s.id, \"rejected\")} className={press} size=\"icon\" variant=\"ghost\">\n                        <XIcon />\n                      </Button>\n                      <Button aria-label={`Accept ${a.name}'s suggestion`} onClick={() => decide(st.s.id, \"accepted\")} className={press} size=\"icon\" variant=\"outline\">\n                        <CheckIcon />\n                      </Button>\n                    </motion.div>\n                  ) : null}\n                </AnimatePresence>\n              </div>\n            </li>\n          );\n        })}\n      </ul>\n    </div>\n    </MotionConfig>\n  );\n}\n"
    }
  ]
}