{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "redline",
  "type": "registry:component",
  "title": "Redline",
  "description": "Suggested edits marked up right inside your sentence, so you can keep or drop each one with a click or a key, and undo any call you change your mind on.",
  "categories": [
    "component",
    "Writing"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/button.json",
    "https://coss.com/ui/r/kbd.json"
  ],
  "files": [
    {
      "path": "registry/devl/redline.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { CheckCheckIcon, CheckIcon, SparklesIcon, Undo2Icon, XIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Fragment, type KeyboardEvent, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Kbd } from \"@/components/ui/kbd\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface DiffChange {\n  del?: string;\n  ins?: string;\n  reason?: string;\n}\n\nexport type DiffSegment = string | DiffChange;\n\nexport interface RedlineProps {\n  segments: DiffSegment[];\n  title?: string;\n  onComplete?: (text: string) => void;\n  className?: string;\n}\n\ntype Decision = \"accepted\" | \"rejected\";\n\nconst spring = { type: \"spring\", stiffness: 440, damping: 32 } as const;\n\nfunction Count({ value }: { value: number }) {\n  const reduce = useReducedMotion();\n  return (\n    <span className=\"relative inline-flex h-4 min-w-[1ch] justify-center overflow-hidden tabular-nums\">\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span\n          animate={{ y: 0, opacity: 1 }}\n          exit={reduce ? { opacity: 0 } : { y: \"-100%\", opacity: 0 }}\n          initial={reduce ? { opacity: 0 } : { y: \"100%\", opacity: 0 }}\n          key={value}\n          transition={spring}\n        >\n          {value}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nexport function Redline({ segments, title = \"Suggested edits\", onComplete, className }: RedlineProps) {\n  const reduce = useReducedMotion();\n  const [decisions, setDecisions] = useState<Record<number, Decision>>({});\n  const [history, setHistory] = useState<number[][]>([]);\n  const [hot, setHot] = useState<number | null>(null);\n  const refs = useRef<Record<number, HTMLSpanElement | null>>({});\n  const warm = useRef(false);\n  const openTimer = useRef<ReturnType<typeof setTimeout>>(undefined);\n  const coolTimer = useRef<ReturnType<typeof setTimeout>>(undefined);\n  const instant = warm.current;\n\n  useEffect(() => {\n    if (hot !== null) {\n      clearTimeout(coolTimer.current);\n      warm.current = true;\n    } else {\n      coolTimer.current = setTimeout(() => {\n        warm.current = false;\n      }, 400);\n    }\n  }, [hot]);\n  useEffect(\n    () => () => {\n      clearTimeout(openTimer.current);\n      clearTimeout(coolTimer.current);\n    },\n    [],\n  );\n\n  const show = (i: number, now = false) => {\n    clearTimeout(openTimer.current);\n    if (now || warm.current) setHot(i);\n    else openTimer.current = setTimeout(() => setHot(i), 120);\n  };\n  const hide = (i: number) => {\n    clearTimeout(openTimer.current);\n    setHot((h) => (h === i ? null : h));\n  };\n\n  const wrapRef = useRef<HTMLDivElement>(null);\n  const barRef = useRef<HTMLSpanElement>(null);\n  const [pos, setPos] = useState<{ i: number; x: number; y: number } | null>(null);\n  useLayoutEffect(() => {\n    if (hot === null) return;\n    const el = refs.current[hot];\n    const wrap = wrapRef.current;\n    const first = el?.getClientRects()[0];\n    if (!wrap || !first) return;\n    const box = wrap.getBoundingClientRect();\n    const half = (barRef.current?.offsetWidth ?? 0) / 2;\n    const center = first.left - box.left + first.width / 2;\n    setPos({ i: hot, x: Math.min(Math.max(center, half + 8), box.width - half - 8), y: first.top - box.top });\n  }, [hot]);\n\n  const changeIdx = segments.flatMap((s, i) => (typeof s === \"string\" ? [] : [i]));\n  const pending = changeIdx.filter((i) => !decisions[i]);\n  const accepted = changeIdx.filter((i) => decisions[i] === \"accepted\").length;\n  const done = pending.length === 0;\n\n  const finalText = segments\n    .map((s, i) => (typeof s === \"string\" ? s : decisions[i] === \"rejected\" ? (s.del ?? \"\") : (s.ins ?? \"\")))\n    .join(\"\");\n\n  const reported = useRef(false);\n  useEffect(() => {\n    if (done && !reported.current) {\n      reported.current = true;\n      onComplete?.(finalText);\n    }\n    if (!done) reported.current = false;\n  }, [done, finalText, onComplete]);\n\n  const focusNext = (from: number, dir: 1 | -1, list = pending) => {\n    const ordered = dir === 1 ? list : [...list].reverse();\n    const next = ordered.find((i) => (dir === 1 ? i > from : i < from)) ?? ordered.find((i) => i !== from);\n    if (next !== undefined) refs.current[next]?.focus();\n  };\n\n  const decide = (i: number, d: Decision) => {\n    setDecisions((prev) => ({ ...prev, [i]: d }));\n    setHistory((h) => [...h, [i]]);\n    focusNext(\n      i,\n      1,\n      pending.filter((p) => p !== i),\n    );\n  };\n\n  const acceptAll = () => {\n    if (!pending.length) return;\n    setDecisions((prev) => ({ ...prev, ...Object.fromEntries(pending.map((i) => [i, \"accepted\" as const])) }));\n    setHistory((h) => [...h, pending]);\n  };\n\n  const undo = () => {\n    const last = history.at(-1);\n    if (!last) return;\n    setHistory((h) => h.slice(0, -1));\n    setDecisions((prev) => {\n      const next = { ...prev };\n      for (const i of last) delete next[i];\n      return next;\n    });\n    setTimeout(() => refs.current[last[0]]?.focus(), 0);\n  };\n\n  const onChangeKey = (e: KeyboardEvent, i: number) => {\n    const k = e.key.toLowerCase();\n    if (k === \"enter\" || k === \"a\" || k === \"y\") {\n      e.preventDefault();\n      decide(i, \"accepted\");\n    } else if (k === \"backspace\" || k === \"delete\" || k === \"r\" || k === \"n\") {\n      e.preventDefault();\n      decide(i, \"rejected\");\n    } else if (k === \"arrowright\" || k === \"arrowdown\") {\n      e.preventDefault();\n      focusNext(i, 1);\n    } else if (k === \"arrowleft\" || k === \"arrowup\") {\n      e.preventDefault();\n      focusNext(i, -1);\n    }\n  };\n\n  const onRootKey = (e: KeyboardEvent) => {\n    if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === \"z\") {\n      e.preventDefault();\n      undo();\n    }\n  };\n\n  return (\n    <section\n      aria-label={title}\n      className={cn(\"w-full max-w-[480px] rounded-2xl border bg-card text-card-foreground shadow-xs/5\", className)}\n      onKeyDown={onRootKey}\n    >\n      <header className=\"flex items-center gap-2 border-b px-4 py-2.5\">\n        <span className=\"flex size-5 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground max-sm:hidden\">\n          <SparklesIcon className=\"size-3\" />\n        </span>\n        <h2 className=\"truncate font-medium text-sm\">{title}</h2>\n        <span className=\"flex shrink-0 items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-muted-foreground text-xs\">\n          <Count value={pending.length} /> left\n        </span>\n        <div className=\"ms-auto flex shrink-0 items-center gap-1\">\n          <Button\n            aria-label=\"Undo last decision\"\n            className=\"transition-[scale,background-color] duration-150 ease-out after:absolute after:-inset-0.5 active:scale-[0.97]\"\n            disabled={!history.length}\n            onClick={undo}\n            size=\"icon-sm\"\n            variant=\"ghost\"\n          >\n            <Undo2Icon />\n          </Button>\n          <Button\n            className=\"transition-[scale,background-color,box-shadow] duration-150 ease-out active:scale-[0.97]\"\n            disabled={done}\n            onClick={acceptAll}\n            size=\"sm\"\n            variant=\"outline\"\n          >\n            <CheckCheckIcon className=\"max-sm:hidden\" />\n            Accept all\n          </Button>\n        </div>\n      </header>\n\n      <div className=\"relative\" ref={wrapRef}>\n      <p className=\"px-5 py-5 text-[15px] text-foreground/90 leading-[1.9]\">\n        {segments.map((seg, i) => {\n          if (typeof seg === \"string\") return <Fragment key={i}>{seg}</Fragment>;\n          const d = decisions[i];\n          if (d) {\n            const text = d === \"accepted\" ? seg.ins : seg.del;\n            if (!text) return null;\n            return (\n              <motion.span\n                animate={{ opacity: 1, filter: \"blur(0px)\" }}\n                className={cn(d === \"accepted\" && \"underline decoration-2 decoration-success/40 underline-offset-4\")}\n                initial={reduce ? false : { opacity: 0, filter: \"blur(3px)\" }}\n                key={`${i}-${d}`}\n                transition={{ duration: 0.25, ease: [0.23, 1, 0.32, 1] }}\n              >\n                {text}\n              </motion.span>\n            );\n          }\n          const label = [\n            seg.del && seg.ins ? `Replace “${seg.del}” with “${seg.ins}”` : seg.ins ? `Insert “${seg.ins}”` : `Delete “${seg.del}”`,\n            seg.reason,\n            \"Enter to accept, Backspace to reject\",\n          ]\n            .filter(Boolean)\n            .join(\". \");\n          return (\n            <span\n              aria-label={label}\n              className=\"group/change cursor-default rounded-md outline-none [box-decoration-break:clone] focus-visible:bg-accent focus-visible:ring-2 focus-visible:ring-ring\"\n              key={i}\n              onBlur={() => hide(i)}\n              onFocus={() => show(i, true)}\n              onMouseEnter={() => show(i)}\n              onMouseLeave={() => hide(i)}\n              onKeyDown={(e) => onChangeKey(e, i)}\n              ref={(el) => {\n                refs.current[i] = el;\n              }}\n              role=\"button\"\n              tabIndex={0}\n            >\n              {seg.del && (\n                <del className=\"rounded-sm bg-destructive/10 px-0.5 text-destructive-foreground decoration-destructive/60 decoration-[1.5px]\">\n                  {seg.del}\n                </del>\n              )}\n              {seg.del && seg.ins && \" \"}\n              {seg.ins && (\n                <ins className=\"rounded-sm bg-success/12 px-0.5 text-success-foreground no-underline\">{seg.ins}</ins>\n              )}\n              <AnimatePresence>\n                {hot === i && (\n                  <motion.span\n                    animate={{ opacity: 1, y: 0, scale: 1 }}\n                    className={cn(\n                      \"absolute z-10 -mt-1 flex -translate-x-1/2 -translate-y-full items-center gap-0.5 whitespace-nowrap rounded-full border bg-popover p-0.5 text-popover-foreground leading-none shadow-md/5\",\n                      pos?.i !== i && \"invisible\",\n                    )}\n                    ref={barRef}\n                    exit={{ opacity: 0, transition: { duration: instant ? 0 : 0.1 } }}\n                    initial={instant ? false : reduce ? { opacity: 0 } : { opacity: 0, y: 3, scale: 0.95 }}\n                    style={{ left: pos?.x ?? 0, top: pos?.y ?? 0, transformOrigin: \"50% 100%\" }}\n                    transition={reduce ? { duration: 0.1 } : { type: \"spring\", stiffness: 600, damping: 38 }}\n                  >\n                    <button\n                      aria-label=\"Accept change\"\n                      className=\"relative flex size-7 cursor-pointer items-center justify-center rounded-full text-success-foreground transition-[background-color,scale] duration-150 ease-out after:absolute after:-inset-0.5 hover:bg-success/12 active:scale-[0.97]\"\n                      onClick={() => decide(i, \"accepted\")}\n                      onMouseDown={(e) => e.preventDefault()}\n                      tabIndex={-1}\n                      type=\"button\"\n                    >\n                      <CheckIcon className=\"size-3.5\" />\n                    </button>\n                    <button\n                      aria-label=\"Reject change\"\n                      className=\"relative flex size-7 cursor-pointer items-center justify-center rounded-full text-destructive-foreground transition-[background-color,scale] duration-150 ease-out after:absolute after:-inset-0.5 hover:bg-destructive/10 active:scale-[0.97]\"\n                      onClick={() => decide(i, \"rejected\")}\n                      onMouseDown={(e) => e.preventDefault()}\n                      tabIndex={-1}\n                      type=\"button\"\n                    >\n                      <XIcon className=\"size-3.5\" />\n                    </button>\n                    {seg.reason && (\n                      <span className=\"max-w-48 truncate px-1.5 text-[11px] text-muted-foreground\">{seg.reason}</span>\n                    )}\n                  </motion.span>\n                )}\n              </AnimatePresence>\n            </span>\n          );\n        })}\n      </p>\n      </div>\n\n      <footer className=\"flex min-h-11 items-center gap-3 border-t px-4 py-2.5 text-muted-foreground text-xs\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {done ? (\n            <motion.span\n              animate={{ opacity: 1, y: 0 }}\n              className=\"flex items-center gap-2\"\n              initial={reduce ? false : { opacity: 0, y: 6 }}\n              key=\"done\"\n              transition={spring}\n            >\n              <span className=\"flex size-4 items-center justify-center rounded-full bg-success text-white\">\n                <CheckIcon className=\"size-2.5\" strokeWidth={3} />\n              </span>\n              <span className=\"font-medium text-foreground\">All changes reviewed</span>\n              <span className=\"tabular-nums\">\n                {accepted} accepted · {changeIdx.length - accepted} rejected\n              </span>\n            </motion.span>\n          ) : (\n            <motion.span\n              animate={{ opacity: 1, y: 0 }}\n              className=\"flex flex-wrap items-center gap-x-3 gap-y-1\"\n              initial={reduce ? false : { opacity: 0, y: 6 }}\n              key=\"hint\"\n              transition={spring}\n            >\n              <span className=\"flex items-center gap-1\">\n                <Kbd>↵</Kbd> accept\n              </span>\n              <span className=\"flex items-center gap-1\">\n                <Kbd>⌫</Kbd> reject\n              </span>\n              <span className=\"flex items-center gap-1\">\n                <Kbd>←</Kbd>\n                <Kbd>→</Kbd> move\n              </span>\n              <span className=\"flex items-center gap-1\">\n                <Kbd>⌘Z</Kbd> undo\n              </span>\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </footer>\n    </section>\n  );\n}\n"
    }
  ]
}