{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "prompt-diff",
  "title": "Prompt Diff",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "Two versions of a prompt compared word by word: removed words struck through and added words underlined so colour is never the only cue, a count of each, {{variables}} marked, side by side or inline, and an optional Restore button.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/prompt-diff.tsx",
      "content": "// Ballmac UI: Prompt Diff. https://ui.ballmac.com/components/prompt-diff\n\"use client\"\n\nimport * as React from \"react\"\nimport { History, Rows2, Columns2 } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype DiffPart = { type: \"same\" | \"add\" | \"del\"; text: string }\n\n/** Splits text into words, {{variables}}, punctuation marks and the spaces between them, so spacing survives and a full stop never makes a word look changed. */\nconst tokenize = (s: string) => s.match(/\\s+|\\{\\{[^}]*\\}\\}|[\\p{L}\\p{N}_'’-]+|[^\\s\\p{L}\\p{N}]/gu) ?? []\n\n/**\n * A word-by-word comparison of two texts using the longest common subsequence. Each part is kept, added or deleted. Very large inputs (over 200,000 word pairs)\n * fall back to \"everything deleted, everything added\" rather than freezing the page.\n */\nfunction diffWords(before: string, after: string): DiffPart[] {\n  const a = tokenize(before)\n  const b = tokenize(after)\n  if (a.length * b.length > 200_000) return [...(before ? [{ type: \"del\" as const, text: before }] : []), ...(after ? [{ type: \"add\" as const, text: after }] : [])]\n  const n = a.length\n  const m = b.length\n  const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0))\n  for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1])\n  const out: DiffPart[] = []\n  const push = (type: DiffPart[\"type\"], text: string) => {\n    const last = out[out.length - 1]\n    if (last && last.type === type) last.text += text\n    else out.push({ type, text })\n  }\n  let i = 0\n  let j = 0\n  while (i < n && j < m) {\n    if (a[i] === b[j]) (push(\"same\", a[i]), i++, j++)\n    else if (lcs[i + 1][j] >= lcs[i][j + 1]) push(\"del\", a[i++])\n    else push(\"add\", b[j++])\n  }\n  while (i < n) push(\"del\", a[i++])\n  while (j < m) push(\"add\", b[j++])\n  return out\n}\n\n/** How many words (and variables) a text holds: runs of letters or digits, and {{variables}}. */\nconst words = (s: string) => s.match(/\\{\\{[^}]*\\}\\}|[\\p{L}\\p{N}_'’-]+/gu)?.length ?? 0\n\n/** Wraps {{variables}} in a quiet chip so they stand out from the prose. */\nfunction Text({ text }: { text: string }) {\n  return (\n    <>\n      {text.split(/(\\{\\{[^}]+\\}\\})/g).map((p, i) =>\n        p.startsWith(\"{{\") ? (\n          <code key={i} className=\"rounded bg-muted px-1 py-px font-mono text-[0.85em]\" dir=\"ltr\">{p}</code>\n        ) : (\n          <React.Fragment key={i}>{p}</React.Fragment>\n        )\n      )}\n    </>\n  )\n}\n\ntype PromptDiffProps = Omit<React.ComponentProps<\"section\">, \"title\"> & {\n  /** The earlier version. */\n  before: string\n  /** The later version. */\n  after: string\n  /** Name of the earlier version, for example \"v12, 3 Oct\". */\n  beforeLabel?: string\n  /** Name of the later version. */\n  afterLabel?: string\n  /** Layout: side by side, or one text with changes marked inline. */\n  mode?: \"split\" | \"unified\"\n  /** Called when the person asks to go back to the earlier version. Adds the button. */\n  onRestore?: () => void\n}\n\n/**\n * Two versions of a prompt compared word by word. Removed words are struck through and added words are underlined, so the difference never depends on colour;\n * a summary counts the words each way. {{variables}} are marked so a change to one is easy to see. Side by side or inline, and an optional Restore button.\n */\nfunction PromptDiff({ before, after, beforeLabel, afterLabel, mode: modeProp = \"split\", onRestore, className, ...props }: PromptDiffProps) {\n  const msg = useMessages()\n  const [mode, setMode] = React.useState(modeProp)\n  beforeLabel ??= msg(\"prompt-diff.before\", \"Before\")\n  afterLabel ??= msg(\"prompt-diff.after\", \"After\")\n  const parts = React.useMemo(() => diffWords(before, after), [before, after])\n  const added = parts.filter((p) => p.type === \"add\").reduce((n, p) => n + words(p.text), 0)\n  const removed = parts.filter((p) => p.type === \"del\").reduce((n, p) => n + words(p.text), 0)\n  const same = before === after\n  const del = (t: string, k: number) => (\n    <del key={k} className=\"rounded-sm bg-destructive/15 text-foreground line-through decoration-destructive decoration-1\">\n      <span className=\"sr-only\">{msg(\"prompt-diff.removedMark\", \"[removed:\")} </span>\n      <Text text={t} />\n      <span className=\"sr-only\">]</span>\n    </del>\n  )\n  const ins = (t: string, k: number) => (\n    <ins key={k} className=\"rounded-sm bg-chart-2/20 text-foreground underline decoration-chart-2 decoration-2 underline-offset-2\">\n      <span className=\"sr-only\">{msg(\"prompt-diff.addedMark\", \"[added:\")} </span>\n      <Text text={t} />\n      <span className=\"sr-only\">]</span>\n    </ins>\n  )\n\n  return (\n    <section data-slot=\"prompt-diff\" className={cn(\"grid w-full min-w-0 gap-4 rounded-xl border bg-card p-4 text-card-foreground sm:p-5\", className)} {...props}>\n      <div className=\"flex flex-wrap items-center justify-between gap-3\">\n        <p aria-live=\"polite\" className=\"text-sm tabular-nums\">\n          {same ? <span className=\"text-muted-foreground\">{msg(\"prompt-diff.same\", \"The two versions are the same.\")}</span> : (\n            <>\n              <span className=\"font-medium\">{msg(\"prompt-diff.added\", \"+{count} words\", { count: added })}</span>\n              <span className=\"mx-2 text-muted-foreground\">·</span>\n              <span className=\"font-medium\">{msg(\"prompt-diff.removed\", \"−{count} words\", { count: removed })}</span>\n            </>\n          )}\n        </p>\n        <div className=\"flex items-center gap-2\">\n          <div role=\"group\" aria-label={msg(\"prompt-diff.view\", \"View\")} className=\"inline-flex rounded-full border p-0.5\">\n            {([[\"split\", Columns2, msg(\"prompt-diff.split\", \"Side by side\")], [\"unified\", Rows2, msg(\"prompt-diff.unified\", \"Inline\")]] as const).map(([k, Icon, l]) => (\n              <button key={k} type=\"button\" aria-pressed={mode === k} onClick={() => setMode(k)} className={cn(\"inline-flex h-7 items-center gap-1.5 rounded-full px-3 text-xs font-medium outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none\", mode === k ? \"bg-foreground text-background\" : \"text-muted-foreground hover:text-foreground\")}>\n                <Icon aria-hidden=\"true\" className=\"size-3.5\" /> {l}\n              </button>\n            ))}\n          </div>\n          {onRestore && (\n            <button type=\"button\" onClick={onRestore} className=\"inline-flex h-8 items-center gap-1.5 rounded-full border bg-background px-3 text-xs font-medium outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none\">\n              <History aria-hidden=\"true\" className=\"size-3.5\" /> {msg(\"prompt-diff.restore\", \"Restore {label}\", { label: beforeLabel })}\n            </button>\n          )}\n        </div>\n      </div>\n\n      {mode === \"split\" ? (\n        <div className=\"grid gap-3 md:grid-cols-2\">\n          {([[beforeLabel, \"del\"], [afterLabel, \"add\"]] as const).map(([label, side]) => (\n            <div key={label} className=\"min-w-0 rounded-lg border\">\n              <p className=\"border-b bg-muted/40 px-3 py-2 text-xs font-medium text-muted-foreground\">{label}</p>\n              <p className=\"whitespace-pre-wrap p-3 text-[13px] leading-relaxed [overflow-wrap:anywhere]\" dir=\"auto\">\n                {parts.map((p, i) => (p.type === \"same\" ? <Text key={i} text={p.text} /> : p.type === side ? (side === \"del\" ? del(p.text, i) : ins(p.text, i)) : null))}\n              </p>\n            </div>\n          ))}\n        </div>\n      ) : (\n        <div className=\"min-w-0 rounded-lg border\">\n          <p className=\"border-b bg-muted/40 px-3 py-2 text-xs font-medium text-muted-foreground\">{beforeLabel} → {afterLabel}</p>\n          <p className=\"whitespace-pre-wrap p-3 text-[13px] leading-relaxed [overflow-wrap:anywhere]\" dir=\"auto\">\n            {parts.map((p, i) => (p.type === \"same\" ? <Text key={i} text={p.text} /> : p.type === \"del\" ? del(p.text, i) : ins(p.text, i)))}\n          </p>\n        </div>\n      )}\n    </section>\n  )\n}\n\nexport { PromptDiff, diffWords, type DiffPart, type PromptDiffProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/prompt-diff.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "ai",
      "prompt",
      "diff",
      "versions",
      "compare",
      "history",
      "text",
      "evals"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "before and after are the two texts; beforeLabel / afterLabel name them; mode is 'split' (default) or 'unified'; onRestore adds a 'Restore <before label>' button. Exports diffWords(before, after) returning [{ type: 'same' | 'add' | 'del', text }]. Compares words and keeps spacing; inputs over 200,000 word pairs are shown as fully replaced.",
      "whenToUse": [
        "Reviewing what changed between two prompt versions before shipping or rolling back",
        "Any short text where the exact words changed matter"
      ],
      "whenNotToUse": [
        "Code or files; use diff-viewer",
        "Comparing model outputs side by side; use parallel-attempts"
      ],
      "composesWith": [
        "parallel-attempts",
        "run-ledger",
        "heat-grid",
        "diff-viewer"
      ],
      "a11y": [
        {
          "keys": "Screen readers",
          "action": "Changes are read as '[removed: old words]' and '[added: new words]'; the counts are a polite live region"
        },
        {
          "keys": "Colour",
          "action": "Removed words are struck through and added words underlined, so the difference never depends on colour"
        },
        {
          "keys": "Tab, Enter / Space",
          "action": "The view switch is two toggle buttons (aria-pressed); Restore is a real button"
        }
      ],
      "customization": [
        "mode and the labels",
        "onRestore",
        "diffWords is exported"
      ]
    },
    "statesNone": "A presentational comparison: you pass the two texts in, so it has nothing of its own to load or fail.",
    "examples": [
      "prompt-diff-demo",
      "prompt-diff-inline"
    ],
    "url": "https://ui.ballmac.com/components/prompt-diff"
  },
  "categories": [
    "ai"
  ],
  "type": "registry:ui"
}