{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"diff-review","type":"registry:component","title":"Diff review","description":"Accept or reject what the AI changed, word by word for prose or line by line for code.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/diff-review.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { X } from \"lucide-react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * DIFF REVIEW: accept or reject what the AI changed\n *\n *   pending   the change inline: removed words struck in red,\n *             added words in green; Reject and Accept\n *   accepted  the text resolves in place: removed words shrink\n *             out of the sentence and added ones lose their\n *             green; Accept morphs to \"Accepted\" as its check\n *             draws itself, Reject folds away, Undo opens in\n *   rejected  the same in reverse: the added words shrink away\n *             and the struck ones come back as plain text\n *\n * The header count rolls (\"3 to review\" → \"2 of 3 accepted\").\n * Give it before and after; it works out the difference by word\n * (prose) or by line (code). Accept or reject one at a time, or\n * all at once. Works controlled or on its own; onComplete fires\n * when nothing is left to review.\n * ───────────────────────────────────────────────────────── */\n\nexport type ChangeDecision = \"pending\" | \"accepted\" | \"rejected\";\n\nexport interface DiffChange {\n  id: string;\n  before: string;\n  after: string;\n  /** Where it is, e.g. \"Paragraph 2\" or a file name. */\n  label?: string;\n}\n\nexport interface DiffReviewProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"title\"> {\n  changes: DiffChange[];\n  /** Compare word by word (prose) or line by line (code). */\n  mode?: \"words\" | \"lines\";\n  decisions?: Record<string, ChangeDecision>;\n  defaultDecisions?: Record<string, ChangeDecision>;\n  /** Every decision after a change (one call for Accept all). */\n  onDecisionsChange?: (decisions: Record<string, ChangeDecision>) => void;\n  onDecisionChange?: (id: string, decision: ChangeDecision) => void;\n  /** Called when the last pending change gets a decision. */\n  onComplete?: (decisions: Record<string, ChangeDecision>) => void;\n  /** Show the decisions without Undo, e.g. once they've been sent. */\n  readOnly?: boolean;\n  title?: string;\n}\n\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst SMALL = `inline-flex h-7 items-center gap-1 rounded-full px-2.5 text-[12px] font-medium transition-colors ${FOCUS}`;\nconst MORPH = \"cubic-bezier(0.16,1,0.3,1)\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\n/** A piece of a line that opens out of nothing and folds back into it. */\nfunction Reveal({ show, reduced, className = \"\", children }: { show: boolean; reduced: boolean; className?: string; children: React.ReactNode }) {\n  return (\n    <span\n      aria-hidden={!show || undefined}\n      className={`grid ${className}`}\n      style={{\n        gridTemplateColumns: show ? \"1fr\" : \"0fr\",\n        opacity: show ? 1 : 0,\n        filter: show ? \"none\" : \"blur(3px)\",\n        transition: reduced ? \"none\" : `grid-template-columns 420ms ${MORPH}, opacity ${show ? \"300ms\" : \"160ms\"} ${MORPH}, filter 300ms ${MORPH}`,\n      }}\n    >\n      <span className=\"flex min-w-0 items-center whitespace-nowrap [clip-path:inset(-4px)]\">{children}</span>\n    </span>\n  );\n}\n\nfunction DrawnCheck({ drawn, reduced }: { drawn: boolean; reduced: boolean }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-3.5\">\n      <path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.8\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        pathLength={1}\n        strokeDasharray={1}\n        style={{ strokeDashoffset: drawn ? 0 : 1, transition: drawn && !reduced ? `stroke-dashoffset 420ms ${MORPH} 120ms` : \"none\" }}\n      />\n    </svg>\n  );\n}\n\ntype Op = { kind: \"same\" | \"add\" | \"remove\"; text: string };\n\n/** Longest-common-subsequence diff over tokens; falls back to a full replace for very large inputs. */\nfunction diff(a: string[], b: string[]): Op[] {\n  if (a.length * b.length > 250_000) {\n    return [...a.map((text) => ({ kind: \"remove\" as const, text })), ...b.map((text) => ({ kind: \"add\" as const, text }))];\n  }\n  const dp = Array.from({ length: a.length + 1 }, () => new Uint32Array(b.length + 1));\n  for (let i = a.length - 1; i >= 0; i--)\n    for (let j = b.length - 1; j >= 0; j--) dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);\n  const ops: Op[] = [];\n  let i = 0;\n  let j = 0;\n  while (i < a.length && j < b.length) {\n    if (a[i] === b[j]) {\n      ops.push({ kind: \"same\", text: a[i] });\n      i++;\n      j++;\n    } else if (dp[i + 1][j] >= dp[i][j + 1]) ops.push({ kind: \"remove\", text: a[i++] });\n    else ops.push({ kind: \"add\", text: b[j++] });\n  }\n  while (i < a.length) ops.push({ kind: \"remove\", text: a[i++] });\n  while (j < b.length) ops.push({ kind: \"add\", text: b[j++] });\n  return ops;\n}\n\ntype Run = { kind: \"same\"; tokens: string[] } | { kind: \"change\"; removed: string[]; added: string[] };\n\n/** Group ops into unchanged runs and change runs (everything removed, then everything added). */\nfunction toRuns(ops: Op[]): Run[] {\n  const runs: Run[] = [];\n  for (const op of ops) {\n    const last = runs[runs.length - 1];\n    if (op.kind === \"same\") {\n      if (last?.kind === \"same\") last.tokens.push(op.text);\n      else runs.push({ kind: \"same\", tokens: [op.text] });\n    } else {\n      let run = last;\n      if (run?.kind !== \"change\") {\n        run = { kind: \"change\", removed: [], added: [] };\n        runs.push(run);\n      }\n      (op.kind === \"remove\" ? run.removed : run.added).push(op.text);\n    }\n  }\n  return runs;\n}\n\n/** Fold short unchanged bits (\"to\", a space) between two changes into them, so a rewrite reads as one phrase. */\nfunction absorbSmall(runs: Run[]): Run[] {\n  const out: Run[] = [];\n  for (let i = 0; i < runs.length; i++) {\n    const run = runs[i];\n    const prev = out[out.length - 1];\n    const next = runs[i + 1];\n    if (run.kind === \"same\" && prev?.kind === \"change\" && next?.kind === \"change\" && run.tokens.join(\"\").trim().length <= 3) {\n      prev.removed.push(...run.tokens, ...next.removed);\n      prev.added.push(...run.tokens, ...next.added);\n      i++;\n    } else if (run.kind === \"change\" && prev?.kind === \"change\") {\n      prev.removed.push(...run.removed);\n      prev.added.push(...run.added);\n    } else out.push(run.kind === \"same\" ? { kind: \"same\", tokens: [...run.tokens] } : { kind: \"change\", removed: [...run.removed], added: [...run.added] });\n  }\n  return out;\n}\n\nconst words = (s: string) => s.split(/(\\s+|[.,;:!?()\"“”])/).filter(Boolean);\nconst lines = (s: string) => s.split(\"\\n\");\n\n/** Words that leave shrink out of the sentence (their font size eases to nothing, so the line closes up around them). */\nconst gone = (out: boolean, reduced: boolean): React.CSSProperties => ({\n  fontSize: out ? 0 : undefined,\n  opacity: out ? 0 : 1,\n  transition: reduced ? \"none\" : `font-size 460ms ${MORPH}, opacity 220ms ${MORPH}, background-color 300ms, color 300ms, text-decoration-color 300ms, margin 460ms ${MORPH}`,\n});\n\nfunction InlineDiff({ before, after, decision, reduced }: { before: string; after: string; decision: ChangeDecision; reduced: boolean }) {\n  const runs = React.useMemo(() => absorbSmall(toRuns(diff(words(before), words(after)))), [before, after]);\n  const pending = decision === \"pending\";\n  return (\n    <p className={`text-[13.5px] leading-relaxed transition-colors duration-300 ${decision === \"rejected\" ? \"text-muted-foreground\" : \"text-foreground\"}`}>\n      {runs.map((run, i) =>\n        run.kind === \"same\" ? (\n          <React.Fragment key={i}>{run.tokens.join(\"\")}</React.Fragment>\n        ) : (\n          <React.Fragment key={i}>\n            {run.removed.length > 0 && (\n              <del\n                aria-hidden={decision === \"accepted\" || undefined}\n                className={`rounded-[3px] ${pending ? \"bg-red-500/10 text-red-600 decoration-red-500/50 dark:text-red-400\" : \"bg-transparent text-inherit decoration-transparent no-underline\"}`}\n                style={gone(decision === \"accepted\", reduced)}\n              >\n                {run.removed.join(\"\")}\n              </del>\n            )}\n            {run.added.length > 0 && (\n              <ins\n                aria-hidden={decision === \"rejected\" || undefined}\n                className={`rounded-[3px] no-underline ${pending ? \"bg-emerald-500/15 text-emerald-700 dark:text-emerald-400\" : \"bg-transparent text-inherit\"}`}\n                style={{ ...gone(decision === \"rejected\", reduced), marginLeft: pending && run.removed.length > 0 ? 2 : 0 }}\n              >\n                {run.added.join(\"\")}\n              </ins>\n            )}\n          </React.Fragment>\n        )\n      )}\n    </p>\n  );\n}\n\nfunction LineDiff({ before, after, decision, reduced }: { before: string; after: string; decision: ChangeDecision; reduced: boolean }) {\n  const ops = React.useMemo(\n    () =>\n      toRuns(diff(lines(before), lines(after))).flatMap((run): Op[] =>\n        run.kind === \"same\"\n          ? run.tokens.map((text) => ({ kind: \"same\", text }))\n          : [...run.removed.map((text) => ({ kind: \"remove\" as const, text })), ...run.added.map((text) => ({ kind: \"add\" as const, text }))]\n      ),\n    [before, after]\n  );\n  const pending = decision === \"pending\";\n  return (\n    <pre className={`overflow-x-auto font-mono text-[12px] leading-5 transition-colors duration-300 ${decision === \"rejected\" ? \"text-muted-foreground\" : \"text-foreground\"}`}>\n      {ops.map((op, i) => {\n        // Lines that don't survive the decision fold shut; the ones that do lose their tint and gutter.\n        const out = (op.kind === \"remove\" && decision === \"accepted\") || (op.kind === \"add\" && decision === \"rejected\");\n        const tint = pending && op.kind !== \"same\";\n        return (\n          <span\n            key={i}\n            aria-hidden={out || undefined}\n            className=\"grid\"\n            style={{ gridTemplateRows: out ? \"0fr\" : \"1fr\", opacity: out ? 0 : 1, transition: reduced ? \"none\" : `grid-template-rows 420ms ${MORPH}, opacity 200ms ${MORPH}` }}\n          >\n            <span\n              className={`flex min-h-0 overflow-hidden transition-colors duration-300 ${\n                tint ? (op.kind === \"add\" ? \"bg-emerald-500/10 text-emerald-700 dark:text-emerald-400\" : \"bg-red-500/10 text-red-600 dark:text-red-400\") : \"\"\n              }`}\n            >\n              <span aria-hidden=\"true\" className=\"w-5 shrink-0 select-none pl-1.5 text-muted-foreground transition-opacity duration-300\" style={{ opacity: tint ? 1 : 0 }}>\n                {op.kind === \"add\" ? \"+\" : op.kind === \"remove\" ? \"−\" : \" \"}\n              </span>\n              <span className=\"pr-3\">{op.text || \" \"}</span>\n              {pending && <span className=\"sr-only\">{op.kind === \"add\" ? \" (added)\" : op.kind === \"remove\" ? \" (removed)\" : \"\"}</span>}\n            </span>\n          </span>\n        );\n      })}\n    </pre>\n  );\n}\n\nexport function DiffReview({\n  changes,\n  mode = \"words\",\n  decisions,\n  defaultDecisions = {},\n  onDecisionsChange,\n  onDecisionChange,\n  onComplete,\n  readOnly = false,\n  title = \"Suggested changes\",\n  className = \"\",\n  ...props\n}: DiffReviewProps) {\n  const reduced = useReducedMotion();\n  const [own, setOwn] = React.useState<Record<string, ChangeDecision>>(defaultDecisions);\n  const current = decisions ?? own;\n  // Where focus goes after a decision: the button that just folded away hands it to the one that took its place.\n  const buttons = React.useRef(new Map<string, HTMLButtonElement | null>());\n  const focusNext = React.useRef<string | null>(null);\n  React.useLayoutEffect(() => {\n    if (!focusNext.current) return;\n    buttons.current.get(focusNext.current)?.focus({ preventScroll: true });\n    focusNext.current = null;\n  });\n  const decisionOf = (id: string) => current[id] ?? \"pending\";\n\n  const commit = (ids: string[], decision: ChangeDecision) => {\n    const next = { ...current, ...Object.fromEntries(ids.map((id) => [id, decision])) };\n    if (decisions === undefined) setOwn(next);\n    onDecisionsChange?.(next);\n    ids.forEach((id) => onDecisionChange?.(id, decision));\n    if (decision !== \"pending\" && changes.every((c) => (next[c.id] ?? \"pending\") !== \"pending\")) onComplete?.(next);\n  };\n  const decide = (id: string, decision: ChangeDecision) => {\n    const hadFocus = [...buttons.current.entries()].some(([key, el]) => key.startsWith(`${id}:`) && el === document.activeElement);\n    if (hadFocus) focusNext.current = decision === \"pending\" ? `${id}:${decisionOf(id) === \"rejected\" ? \"reject\" : \"accept\"}` : `${id}:undo`;\n    commit([id], decision);\n  };\n  const decideAll = (decision: ChangeDecision) =>\n    commit(changes.filter((c) => decisionOf(c.id) === \"pending\").map((c) => c.id), decision);\n\n  const pending = changes.filter((c) => decisionOf(c.id) === \"pending\").length;\n  const accepted = changes.filter((c) => decisionOf(c.id) === \"accepted\").length;\n\n  return (\n    <div className={`w-full ${className}`} {...props}>\n      <div className=\"mb-3 flex min-h-7 flex-wrap items-center justify-between gap-2\">\n        <p className=\"flex items-baseline gap-[0.3em] text-[13px] font-medium text-foreground\">\n          {title}\n          {/* \"3 to review\" → \"2 of 3 accepted\": the count rolls and the words morph. */}\n          <span aria-hidden=\"true\" className=\"inline-flex items-baseline font-normal tabular-nums text-muted-foreground\">\n            <NumberRoll value={pending > 0 ? pending : accepted} duration={600} />\n            <Reveal show={pending === 0} reduced={reduced}>\n              &nbsp;of&nbsp;\n              <NumberRoll value={changes.length} duration={600} />\n            </Reveal>\n            &nbsp;\n            <TextMorph>{pending > 0 ? \"to review\" : \"accepted\"}</TextMorph>\n          </span>\n          <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n            {pending > 0 ? `${pending} to review` : `${accepted} of ${changes.length} accepted`}\n          </span>\n        </p>\n        {!readOnly && (\n          <Reveal show={pending > 1} reduced={reduced}>\n            <span className=\"flex gap-1 p-0.5\">\n              <button type=\"button\" inert={pending < 2} onClick={() => decideAll(\"rejected\")} className={`${SMALL} text-muted-foreground hover:bg-accent hover:text-foreground`}>\n                Reject all\n              </button>\n              <button type=\"button\" inert={pending < 2} onClick={() => decideAll(\"accepted\")} className={`${SMALL} bg-primary text-primary-foreground hover:bg-primary/90`}>\n                Accept all\n              </button>\n            </span>\n          </Reveal>\n        )}\n      </div>\n\n      <ul className=\"flex flex-col gap-2\">\n        {changes.map((change) => {\n          const decision = decisionOf(change.id);\n          const open = decision === \"pending\" && !readOnly;\n          const name = change.label ?? \"change\";\n          const ref = (key: string) => (el: HTMLButtonElement | null) => {\n            buttons.current.set(`${change.id}:${key}`, el);\n          };\n          return (\n            <li key={change.id} className={`overflow-hidden rounded-xl border transition-colors duration-300 ${decision === \"pending\" ? \"border-border\" : \"border-border/60\"}`}>\n              <div className=\"flex min-h-10 items-center justify-between gap-3 border-b border-border/60 py-1 pl-3 pr-1.5\">\n                <span className=\"truncate text-[11.5px] text-muted-foreground\">{change.label ?? \"Change\"}</span>\n                {/* The button you press becomes the answer: \"Accept\" morphs to \"Accepted\" while the other folds away. */}\n                <span className=\"flex shrink-0 items-center\">\n                  <Reveal show={decision === \"rejected\" || open} reduced={reduced}>\n                    <span className=\"block p-0.5\">\n                      <button\n                        ref={ref(\"reject\")}\n                        type=\"button\"\n                        inert={!open}\n                        aria-label={`Reject ${name}`}\n                        onClick={() => decide(change.id, \"rejected\")}\n                        className={`${SMALL} ${open ? \"text-muted-foreground hover:bg-accent hover:text-foreground\" : \"text-muted-foreground\"}`}\n                      >\n                        <X aria-hidden=\"true\" className=\"size-3.5\" />\n                        <TextMorph>{decision === \"rejected\" ? \"Rejected\" : \"Reject\"}</TextMorph>\n                      </button>\n                    </span>\n                  </Reveal>\n                  <Reveal show={decision === \"accepted\" || open} reduced={reduced}>\n                    <span className=\"block p-0.5\">\n                      <button\n                        ref={ref(\"accept\")}\n                        type=\"button\"\n                        inert={!open}\n                        aria-label={`Accept ${name}`}\n                        onClick={() => decide(change.id, \"accepted\")}\n                        className={`${SMALL} ${open ? \"text-foreground hover:bg-accent\" : \"text-emerald-600 dark:text-emerald-400\"}`}\n                      >\n                        <span aria-hidden=\"true\" className=\"relative flex size-3.5 items-center justify-center\">\n                          {/* The plain check fades as a green one draws itself over it. */}\n                          <svg\n                            viewBox=\"0 0 16 16\"\n                            fill=\"none\"\n                            className=\"absolute size-3.5\"\n                            style={{ opacity: open ? 1 : 0, transition: reduced ? \"none\" : `opacity 200ms ${MORPH}` }}\n                          >\n                            <path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                          </svg>\n                          <span className=\"absolute inset-0 flex items-center justify-center\">\n                            <DrawnCheck drawn={decision === \"accepted\"} reduced={reduced} />\n                          </span>\n                        </span>\n                        <TextMorph>{decision === \"accepted\" ? \"Accepted\" : \"Accept\"}</TextMorph>\n                      </button>\n                    </span>\n                  </Reveal>\n                  {!readOnly && (\n                    <Reveal show={decision !== \"pending\"} reduced={reduced}>\n                      <span className=\"block p-0.5 pl-1.5\">\n                        <button\n                          ref={ref(\"undo\")}\n                          type=\"button\"\n                          inert={decision === \"pending\"}\n                          onClick={() => decide(change.id, \"pending\")}\n                          className={`rounded px-1 text-[12px] font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground ${FOCUS}`}\n                        >\n                          Undo\n                        </button>\n                      </span>\n                    </Reveal>\n                  )}\n                </span>\n              </div>\n              <div className={mode === \"lines\" ? \"py-2\" : \"px-3 py-2.5\"}>\n                {mode === \"lines\" ? (\n                  <LineDiff before={change.before} after={change.after} decision={decision} reduced={reduced} />\n                ) : (\n                  <InlineDiff before={change.before} after={change.after} decision={decision} reduced={reduced} />\n                )}\n              </div>\n            </li>\n          );\n        })}\n      </ul>\n    </div>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/diff-review"}