{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"inline-diff","type":"registry:component","title":"Inline Diff Text","description":"Original, revision and reason — revealed in place, legibly.","dependencies":[],"categories":["systems","diff","editing","version-history","prose"],"files":[{"path":"components/lab/inline-diff/inline-diff.tsx","target":"components/lab/inline-diff/inline-diff.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useId, useMemo, useState, type ReactNode } from \"react\";\nimport { diffWords, type DiffSegment } from \"./word-diff\";\nimport \"./inline-diff.css\";\n\nexport type InlineDiffChange = {\n  id: string;\n  before: string;\n  after: string;\n  reason: string;\n  author?: string;\n  at?: string;\n  segments?: DiffSegment[];\n};\n\ntype InlineDiffTextProps = {\n  change: InlineDiffChange;\n  /**\n   * Pre-computed word segments. Takes precedence over `change.segments`\n   * and over diffing `before` / `after` with the bundled LCS.\n   */\n  segments?: DiffSegment[];\n  /** Show the reason on first render. */\n  defaultOpen?: boolean;\n  /** When provided, an accept control appears next to the reason. */\n  onAccept?: () => void;\n  /** How strongly the change is tinted. */\n  emphasis?: \"quiet\" | \"marked\";\n  className?: string;\n};\n\n/**\n * INLINE DIFF TEXT — a sentence that changed, shown as a sentence.\n *\n * The removed words stay in place with a strikethrough and a muted\n * wash, the revision sits beside them, and the reason is one button\n * away. Removed and added runs carry screen-reader-only \"removed:\" /\n * \"added:\" prefixes, so the change is never carried by colour alone.\n *\n * Accepting is the host's call: pressing Accept reports through\n * onAccept and settles the local view on the revision — the reason\n * is kept, not discarded.\n */\nexport function InlineDiffText({\n  change,\n  segments,\n  defaultOpen = false,\n  onAccept,\n  emphasis = \"marked\",\n  className,\n}: InlineDiffTextProps) {\n  const [reasonOpen, setReasonOpen] = useState(defaultOpen);\n  const [lastDefaultOpen, setLastDefaultOpen] = useState(defaultOpen);\n  const [accepted, setAccepted] = useState(false);\n  const panelId = useId();\n\n  // Uncontrolled, but a host that changes defaultOpen (a preview\n  // control, a reset to defaults) should see the reason follow.\n  if (defaultOpen !== lastDefaultOpen) {\n    setLastDefaultOpen(defaultOpen);\n    setReasonOpen(defaultOpen);\n  }\n\n  const diff = useMemo(\n    () => segments ?? change.segments ?? diffWords(change.before, change.after),\n    [segments, change.segments, change.before, change.after],\n  );\n\n  const accept = () => {\n    setAccepted(true);\n    onAccept?.();\n  };\n\n  return (\n    <section\n      className={`id-root${className ? ` ${className}` : \"\"}`}\n      data-emphasis={emphasis}\n      data-settled={accepted}\n      data-reason-open={reasonOpen}\n    >\n      <p className=\"id-sentence\">\n        {accepted ? change.after : renderSegments(diff)}\n      </p>\n\n      <div className=\"id-row\">\n        {accepted ? (\n          <span className=\"id-accepted\">\n            <svg\n              viewBox=\"0 0 16 16\"\n              width=\"11\"\n              height=\"11\"\n              aria-hidden=\"true\"\n              focusable=\"false\"\n            >\n              <path\n                d=\"M3 8.5 6.4 12 13 4.5\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth=\"2\"\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n              />\n            </svg>\n            Accepted · reason kept\n          </span>\n        ) : null}\n\n        <button\n          type=\"button\"\n          className=\"id-reason\"\n          aria-expanded={reasonOpen}\n          aria-controls={panelId}\n          onClick={() => setReasonOpen((current) => !current)}\n        >\n          Reason\n          <svg\n            className=\"id-chevron\"\n            viewBox=\"0 0 16 16\"\n            width=\"11\"\n            height=\"11\"\n            aria-hidden=\"true\"\n            focusable=\"false\"\n          >\n            <path\n              d=\"M4 6.25 8 10.25 12 6.25\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth=\"1.8\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            />\n          </svg>\n        </button>\n\n        {onAccept && !accepted ? (\n          <button type=\"button\" className=\"id-accept\" onClick={accept}>\n            Accept\n          </button>\n        ) : null}\n      </div>\n\n      <div\n        className=\"id-panel\"\n        id={panelId}\n        data-open={reasonOpen}\n        aria-hidden={!reasonOpen}\n        inert={!reasonOpen}\n      >\n        <div className=\"id-panel-inner\">\n          <p className=\"id-reason-text\">{change.reason}</p>\n          {change.author || change.at ? (\n            <p className=\"id-byline\">\n              {change.author ? <span>{change.author}</span> : null}\n              {change.author && change.at ? (\n                <span aria-hidden=\"true\">·</span>\n              ) : null}\n              {change.at ? <span title={change.at}>{change.at}</span> : null}\n            </p>\n          ) : null}\n        </div>\n      </div>\n\n      <p className=\"id-sr\" role=\"status\">\n        {accepted ? \"Revision accepted.\" : \"\"}\n      </p>\n    </section>\n  );\n}\n\n/**\n * Removed and added runs are announced with their kind in a hidden\n * prefix. Tokens keep their trailing whitespace, so a final token\n * without one gets a shown space to stay legible against its\n * neighbour.\n */\nfunction renderSegments(segments: DiffSegment[]): ReactNode {\n  return segments.map((segment, index) => {\n    if (segment.kind === \"same\") {\n      return <span key={index}>{segment.text}</span>;\n    }\n\n    const gap = /\\s$/.test(segment.text) ? \"\" : \" \";\n\n    if (segment.kind === \"removed\") {\n      return (\n        <del className=\"id-removed\" key={index}>\n          <span className=\"id-sr\">{\"removed: \"}</span>\n          {segment.text}\n          {gap}\n        </del>\n      );\n    }\n\n    return (\n      <ins className=\"id-added\" key={index}>\n        <span className=\"id-sr\">{\"added: \"}</span>\n        {segment.text}\n        {gap}\n      </ins>\n    );\n  });\n}\n"},{"path":"components/lab/inline-diff/word-diff.ts","target":"components/lab/inline-diff/word-diff.ts","type":"registry:file","content":"/**\n * WORD DIFF — pure, dependency-free token diffing.\n *\n * Both strings are tokenised into words with their trailing\n * whitespace attached, so joining a run of tokens reconstructs the\n * original text exactly. A standard LCS dynamic program then walks the\n * two token arrays backwards and emits same / removed / added\n * segments in order.\n *\n * The work is bounded: past 400 tokens per side the diff degrades to\n * one removed and one added block instead of a quadratic table.\n */\n\nexport type DiffSegment = {\n  kind: \"same\" | \"removed\" | \"added\";\n  text: string;\n};\n\n/** Above this many tokens per side, fall back to a whole-string swap. */\nconst MAX_TOKENS = 400;\n\n/** A word (with any trailing whitespace) or a run of whitespace. */\nconst TOKEN_PATTERN = /\\s+|\\S+\\s*/g;\n\nfunction tokenise(input: string): string[] {\n  return input.match(TOKEN_PATTERN) ?? [];\n}\n\nexport function diffWords(before: string, after: string): DiffSegment[] {\n  if (before === after) {\n    return before.length > 0 ? [{ kind: \"same\", text: before }] : [];\n  }\n\n  const a = tokenise(before);\n  const b = tokenise(after);\n\n  if (a.length > MAX_TOKENS || b.length > MAX_TOKENS) {\n    return [\n      { kind: \"removed\", text: before },\n      { kind: \"added\", text: after },\n    ];\n  }\n\n  const width = b.length + 1;\n  const table = new Uint32Array((a.length + 1) * width);\n\n  for (let i = 1; i <= a.length; i += 1) {\n    for (let j = 1; j <= b.length; j += 1) {\n      table[i * width + j] =\n        a[i - 1] === b[j - 1]\n          ? table[(i - 1) * width + (j - 1)] + 1\n          : Math.max(\n              table[(i - 1) * width + j],\n              table[i * width + (j - 1)],\n            );\n    }\n  }\n\n  // Walk the table from the end; the ops come out reversed.\n  const ops: DiffSegment[] = [];\n  let i = a.length;\n  let j = b.length;\n\n  while (i > 0 && j > 0) {\n    if (a[i - 1] === b[j - 1]) {\n      ops.push({ kind: \"same\", text: a[i - 1] });\n      i -= 1;\n      j -= 1;\n    } else if (table[(i - 1) * width + j] >= table[i * width + (j - 1)]) {\n      ops.push({ kind: \"removed\", text: a[i - 1] });\n      i -= 1;\n    } else {\n      ops.push({ kind: \"added\", text: b[j - 1] });\n      j -= 1;\n    }\n  }\n\n  while (i > 0) {\n    i -= 1;\n    ops.push({ kind: \"removed\", text: a[i] });\n  }\n\n  while (j > 0) {\n    j -= 1;\n    ops.push({ kind: \"added\", text: b[j] });\n  }\n\n  ops.reverse();\n  return groupChanges(ops);\n}\n\n/**\n * Inside one change region the walk can interleave removals and\n * additions. Rendering reads better with the removals first, and\n * neither string is affected: only the order of tokens within a\n * region changes, never their membership or their order within a\n * kind. Consecutive ops of the same kind are merged here too.\n */\nfunction groupChanges(ops: DiffSegment[]): DiffSegment[] {\n  const grouped: DiffSegment[] = [];\n  let index = 0;\n\n  while (index < ops.length) {\n    if (ops[index].kind === \"same\") {\n      const previous = grouped[grouped.length - 1];\n      if (previous && previous.kind === \"same\") previous.text += ops[index].text;\n      else grouped.push({ kind: \"same\", text: ops[index].text });\n      index += 1;\n      continue;\n    }\n\n    let removed = \"\";\n    let added = \"\";\n    while (index < ops.length && ops[index].kind !== \"same\") {\n      if (ops[index].kind === \"removed\") removed += ops[index].text;\n      else added += ops[index].text;\n      index += 1;\n    }\n\n    if (removed.length > 0) grouped.push({ kind: \"removed\", text: removed });\n    if (added.length > 0) grouped.push({ kind: \"added\", text: added });\n  }\n\n  return grouped;\n}\n"},{"path":"components/lab/inline-diff/inline-diff.css","target":"components/lab/inline-diff/inline-diff.css","type":"registry:file","content":"/* ============================================================\n   INLINE DIFF TEXT — self-contained styles.\n\n   Every token is scoped to the root with a fallback, so the\n   component carries its own look into any codebase that has\n   never heard of this stylesheet.\n   ============================================================ */\n\n.id-root {\n  --id-ink: #141418;\n  --id-muted: #5f5f68;\n  --id-faint: #8c8c94;\n  --id-line: rgba(20, 20, 24, 0.1);\n  --id-line-soft: rgba(20, 20, 24, 0.06);\n  --id-remove: #b3261e;\n  --id-add: #137a4e;\n  --id-focus: #4f27e0;\n  --id-reading: Georgia, \"Iowan Old Style\", \"Times New Roman\", ui-serif, serif;\n  --id-mono: ui-monospace, \"SF Mono\", \"Cascadia Mono\", Consolas, monospace;\n  --id-ease: cubic-bezier(0.22, 1, 0.36, 1);\n\n  color: var(--id-ink);\n  font-size: 0.9375rem;\n  line-height: 1.6;\n}\n\n.id-root,\n.id-root *,\n.id-root *::before,\n.id-root *::after {\n  box-sizing: border-box;\n}\n\n/* ---- the sentence ------------------------------------------ */\n\n.id-sentence {\n  margin: 0;\n  font-family: var(--id-reading);\n  font-size: 1.0625rem;\n  line-height: 1.65;\n  letter-spacing: -0.003em;\n  overflow-wrap: anywhere;\n}\n\n.id-removed,\n.id-added {\n  padding: 0 0.14em;\n  border-radius: 3px;\n  -webkit-box-decoration-break: clone;\n  box-decoration-break: clone;\n}\n\n.id-removed {\n  background: rgba(179, 38, 30, 0.1);\n  color: inherit;\n  text-decoration: line-through;\n  text-decoration-color: rgba(179, 38, 30, 0.62);\n  text-decoration-thickness: 1px;\n}\n\n.id-added {\n  background: rgba(19, 122, 78, 0.13);\n  color: inherit;\n  text-decoration: none;\n}\n\n/* Quiet emphasis sits inside prose: marks, not washes. */\n.id-root[data-emphasis=\"quiet\"] .id-removed {\n  padding: 0;\n  background: transparent;\n  color: var(--id-muted);\n  text-decoration: line-through;\n  text-decoration-style: dotted;\n  text-decoration-color: rgba(179, 38, 30, 0.75);\n}\n\n.id-root[data-emphasis=\"quiet\"] .id-added {\n  padding: 0;\n  background: transparent;\n  text-decoration: underline;\n  text-decoration-style: dotted;\n  text-decoration-color: rgba(19, 122, 78, 0.8);\n  text-decoration-thickness: 1px;\n  text-underline-offset: 3px;\n}\n\n/* ---- the reason row ---------------------------------------- */\n\n.id-row {\n  display: flex;\n  flex-wrap: wrap;\n  align-items: center;\n  gap: 8px 12px;\n  margin-top: 9px;\n}\n\n.id-reason,\n.id-accept {\n  display: inline-flex;\n  align-items: center;\n  gap: 5px;\n  margin: 0;\n  padding: 3px 10px;\n  border: 1px solid var(--id-line);\n  border-radius: 999px;\n  background: #ffffff;\n  color: var(--id-muted);\n  font-family: var(--id-mono);\n  font-size: 0.6875rem;\n  letter-spacing: 0.03em;\n  cursor: pointer;\n}\n\n.id-reason:hover {\n  border-color: rgba(20, 20, 24, 0.22);\n  color: var(--id-ink);\n}\n\n.id-reason:focus-visible,\n.id-accept:focus-visible {\n  outline: 2px solid var(--id-focus);\n  outline-offset: 2px;\n}\n\n.id-chevron {\n  transition: transform 180ms var(--id-ease);\n}\n\n.id-root[data-reason-open=\"true\"] .id-chevron {\n  transform: rotate(180deg);\n}\n\n.id-accept {\n  border-color: var(--id-ink);\n  background: var(--id-ink);\n  color: #ffffff;\n}\n\n.id-accept:hover {\n  background: #000000;\n}\n\n.id-accepted {\n  display: inline-flex;\n  align-items: center;\n  gap: 5px;\n  font-family: var(--id-mono);\n  font-size: 0.6875rem;\n  letter-spacing: 0.03em;\n  color: var(--id-add);\n}\n\n/* ---- the revealed reason ----------------------------------- */\n\n.id-panel {\n  display: grid;\n  grid-template-rows: 0fr;\n  transition: grid-template-rows 220ms var(--id-ease);\n}\n\n.id-panel[data-open=\"true\"] {\n  grid-template-rows: 1fr;\n}\n\n.id-panel-inner {\n  min-height: 0;\n  overflow: hidden;\n}\n\n.id-reason-text {\n  margin: 9px 0 0;\n  padding: 9px 12px;\n  border-left: 2px solid var(--id-line);\n  border-radius: 0 8px 8px 0;\n  background: var(--id-line-soft);\n  font-size: 0.8125rem;\n  line-height: 1.55;\n  color: var(--id-muted);\n  overflow-wrap: anywhere;\n}\n\n.id-byline {\n  display: flex;\n  flex-wrap: wrap;\n  align-items: baseline;\n  gap: 6px;\n  margin: 6px 0 0;\n  font-family: var(--id-mono);\n  font-size: 0.6875rem;\n  color: var(--id-faint);\n}\n\n.id-byline span[aria-hidden=\"true\"] {\n  color: rgba(20, 20, 24, 0.25);\n}\n\n/* The component ships its own sr-only, so it never depends on the host. */\n.id-sr {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  padding: 0;\n  margin: -1px;\n  overflow: hidden;\n  clip: rect(0 0 0 0);\n  clip-path: inset(50%);\n  white-space: nowrap;\n  border: 0;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .id-panel,\n  .id-chevron {\n    transition: none;\n  }\n}\n"}]}