{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "diff-review",
  "title": "DiffReview",
  "description": "A decision per hunk before anything lands. J and K move, Y and N decide.",
  "registryDependencies": [
    "https://creance.pages.dev/r/badge.json",
    "https://creance.pages.dev/r/button.json",
    "https://creance.pages.dev/r/code.json",
    "https://creance.pages.dev/r/creance-base.json",
    "https://creance.pages.dev/r/diff-line.json",
    "https://creance.pages.dev/r/keyboard-hint.json"
  ],
  "files": [
    {
      "path": "diff-review/DiffReview.css",
      "content": ".cr-diff-review {\n  display: grid;\n  gap: var(--cr-space-3);\n  color: var(--cr-color-text-default);\n  font-family: var(--cr-font-family-sans);\n  font-size: var(--cr-font-size-md);\n}\n\n.cr-diff-review__header {\n  display: flex;\n  flex-wrap: wrap;\n  align-items: baseline;\n  justify-content: space-between;\n  gap: var(--cr-space-2);\n}\n\n.cr-diff-review__title {\n  margin: 0;\n  letter-spacing: var(--cr-font-tracking-tight);\n  font-size: var(--cr-font-size-lg);\n  font-weight: var(--cr-font-weight-semibold);\n}\n\n.cr-diff-review__stat {\n  font-family: var(--cr-font-family-mono);\n  font-size: var(--cr-font-size-sm);\n}\n\n.cr-diff-review__added { color: var(--cr-color-diff-added-fg); }\n.cr-diff-review__removed { color: var(--cr-color-diff-removed-fg); }\n\n.cr-diff-review__description {\n  margin: 0;\n  color: var(--cr-color-text-muted);\n}\n\n.cr-diff-review__hunks {\n  display: grid;\n  gap: var(--cr-space-3);\n}\n\n.cr-diff-review__hunk {\n  overflow: hidden;\n  border-radius: var(--cr-radius-lg);\n}\n\n.cr-diff-review__hunk:focus-visible {\n  outline: var(--cr-focus-width) solid var(--cr-color-focus);\n  outline-offset: var(--cr-focus-offset);\n}\n\n.cr-diff-review__hunk[data-decision='accepted'] {\n  border-color: var(--cr-color-status-done-fg);\n}\n\n.cr-diff-review__hunk[data-decision='rejected'] {\n  border-color: var(--cr-color-status-failed-fg);\n}\n\n/* Rejected hunks are marked by a dashed border and the Reject state, not by dimming: dimmed text fails contrast. */\n.cr-diff-review__hunk[data-decision='rejected'] {\n  border-style: dashed;\n}\n\n.cr-diff-review__hunk-bar {\n  display: flex;\n  flex-wrap: wrap;\n  align-items: center;\n  justify-content: space-between;\n  gap: var(--cr-space-2);\n  padding: var(--cr-space-2) var(--cr-space-3);\n  border-bottom: 1px solid var(--cr-color-border-default);\n}\n\n.cr-diff-review__hunk-title {\n  display: flex;\n  flex-wrap: wrap;\n  gap: var(--cr-space-2);\n  margin: 0;\n  font-size: var(--cr-font-size-sm);\n  font-weight: var(--cr-font-weight-semibold);\n}\n\n.cr-diff-review__hunk-header {\n  color: var(--cr-color-text-muted);\n  font-family: var(--cr-font-family-mono);\n  font-weight: var(--cr-font-weight-regular);\n}\n\n.cr-diff-review__hunk-actions {\n  display: flex;\n  gap: var(--cr-space-1);\n}\n\n.cr-diff-review__hunk-actions .cr-button[data-choice='accept'][aria-pressed='true'] {\n  border-color: var(--cr-color-status-done-fg);\n  background: var(--cr-color-status-done-bg);\n  color: var(--cr-color-status-done-fg);\n}\n\n.cr-diff-review__hunk-actions .cr-button[data-choice='reject'][aria-pressed='true'] {\n  border-color: var(--cr-color-status-failed-fg);\n  background: var(--cr-color-status-failed-bg);\n  color: var(--cr-color-status-failed-fg);\n}\n\n.cr-diff-review__lines {\n  overflow-x: auto;\n  padding-block: var(--cr-space-1);\n}\n\n.cr-diff-review__bar {\n  position: sticky;\n  bottom: 0;\n  display: flex;\n  flex-wrap: wrap;\n  align-items: center;\n  justify-content: space-between;\n  gap: var(--cr-space-2);\n  padding: var(--cr-space-2) var(--cr-space-2) var(--cr-space-2) var(--cr-space-4);\n  border-radius: var(--cr-radius-xl);\n  font-size: var(--cr-font-size-sm);\n}\n\n.cr-diff-review__progress {\n  display: flex;\n  flex-wrap: wrap;\n  align-items: center;\n  gap: var(--cr-space-3);\n}\n\n.cr-diff-review__bar-actions {\n  display: flex;\n  flex-wrap: wrap;\n  gap: var(--cr-space-2);\n}\n",
      "type": "registry:file",
      "target": "@components/creance/diff-review/DiffReview.css"
    },
    {
      "path": "diff-review/DiffReview.tsx",
      "content": "'use client';\n\nimport { useId, useRef, useState, type KeyboardEvent } from 'react';\nimport { Badge } from '@/components/creance/badge/Badge';\nimport { Button } from '@/components/creance/button/Button';\nimport { Code } from '@/components/creance/code/Code';\nimport { DiffLine, type DiffLineType } from '@/components/creance/diff-line/DiffLine';\nimport { KeyboardHint } from '@/components/creance/keyboard-hint/KeyboardHint';\nimport { cx } from '@/lib/creance/cx';\nimport { CheckIcon, CrossIcon } from '@/lib/creance/icons';\nimport { LiveRegion } from '@/lib/creance/LiveRegion';\nimport './DiffReview.css';\n\nexport interface DiffHunkLine {\n  type: DiffLineType;\n  content: string;\n  oldNumber?: number | undefined;\n  newNumber?: number | undefined;\n}\n\nexport interface DiffHunk {\n  id: string;\n  /** The `@@ -12,4 +12,6 @@` header. */\n  header: string;\n  lines: DiffHunkLine[];\n}\n\nexport type HunkDecision = 'accepted' | 'rejected';\n\nexport interface DiffReviewProps {\n  file: string;\n  hunks: DiffHunk[];\n  /** Called once every hunk has a decision and the person applies them. */\n  onApply?: (result: { accepted: string[]; rejected: string[] }) => void;\n  /** Once set, the review is read-only and shows what was applied. */\n  applied?: { accepted: string[]; rejected: string[] } | undefined;\n  /** What the change is for, in the agent's words. */\n  description?: string | undefined;\n  headingLevel?: 2 | 3 | 4;\n  className?: string;\n}\n\nfunction counts(hunks: DiffHunk[]) {\n  let added = 0;\n  let removed = 0;\n  for (const line of hunks.flatMap((h) => h.lines)) {\n    if (line.type === 'add') added++;\n    if (line.type === 'remove') removed++;\n  }\n  return { added, removed };\n}\n\n/**\n * Keyboard: J / K move between hunks, Y accepts and N rejects the focused hunk.\n * Shortcuts only work while focus is inside the review.\n */\nexport function DiffReview({ file, hunks, onApply, applied, description, headingLevel = 3, className }: DiffReviewProps) {\n  const titleId = useId();\n  const [decisions, setDecisions] = useState<Record<string, HunkDecision>>({});\n  const [message, setMessage] = useState('');\n  const hunkRefs = useRef<Array<HTMLElement | null>>([]);\n  const Heading = `h${headingLevel}` as const;\n  const HunkHeading = `h${headingLevel + 1}` as 'h3' | 'h4' | 'h5';\n\n  const readOnly = applied !== undefined;\n  const decisionFor = (id: string): HunkDecision | undefined =>\n    applied\n      ? applied.accepted.includes(id)\n        ? 'accepted'\n        : applied.rejected.includes(id)\n          ? 'rejected'\n          : undefined\n      : decisions[id];\n  const decided = hunks.filter((h) => decisionFor(h.id)).length;\n  const accepted = hunks.filter((h) => decisionFor(h.id) === 'accepted').map((h) => h.id);\n  const rejected = hunks.filter((h) => decisionFor(h.id) === 'rejected').map((h) => h.id);\n  const { added, removed } = counts(hunks);\n\n  function decide(id: string, decision: HunkDecision) {\n    setDecisions((d) => ({ ...d, [id]: decision }));\n  }\n  function decideAll(decision: HunkDecision) {\n    setDecisions(Object.fromEntries(hunks.map((h) => [h.id, decision])));\n    setMessage(decision === 'accepted' ? 'All hunks accepted' : 'All hunks rejected');\n  }\n  function apply() {\n    onApply?.({ accepted, rejected });\n    setMessage(`Applied ${accepted.length} of ${hunks.length} hunks`);\n  }\n\n  function onKeyDown(e: KeyboardEvent<HTMLElement>) {\n    if (e.metaKey || e.ctrlKey || e.altKey) return;\n    const current = hunkRefs.current.findIndex((el) => el?.contains(document.activeElement));\n    const key = e.key.toLowerCase();\n    if (key === 'j' || key === 'k') {\n      const next = key === 'j' ? Math.min(current + 1, hunks.length - 1) : Math.max(current - 1, 0);\n      hunkRefs.current[current === -1 ? 0 : next]?.focus();\n    } else if (!readOnly && current !== -1 && (key === 'y' || key === 'n')) {\n      const hunk = hunks[current];\n      if (!hunk) return;\n      decide(hunk.id, key === 'y' ? 'accepted' : 'rejected');\n      setMessage(`Hunk ${current + 1} ${key === 'y' ? 'accepted' : 'rejected'}`);\n    } else {\n      return;\n    }\n    e.preventDefault();\n  }\n\n  return (\n    // Keyboard shortcuts are a convenience on top of the buttons, which stay fully usable.\n    // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions\n    <section\n      className={cx('cr-diff-review', className)}\n      aria-labelledby={titleId}\n      onKeyDown={onKeyDown}\n      data-readonly={readOnly || undefined}\n    >\n      <header className=\"cr-diff-review__header\">\n        <Heading id={titleId} className=\"cr-diff-review__title\">\n          <span className=\"cr-visually-hidden\">Review changes to </span>\n          <Code>{file}</Code>\n        </Heading>\n        <span className=\"cr-diff-review__stat\">\n          <span className=\"cr-diff-review__added\">+{added}</span>{' '}\n          <span className=\"cr-diff-review__removed\">−{removed}</span>\n          <span className=\"cr-visually-hidden\">\n            : {added} lines added, {removed} removed\n          </span>\n        </span>\n      </header>\n      {description && <p className=\"cr-diff-review__description\">{description}</p>}\n\n      <div className=\"cr-diff-review__hunks\">\n        {hunks.map((hunk, i) => {\n          const decision = decisionFor(hunk.id);\n          const headingId = `${titleId}-h${i}`;\n          return (\n            <section\n              key={hunk.id}\n              ref={(el) => {\n                hunkRefs.current[i] = el;\n              }}\n              className=\"cr-diff-review__hunk cr-surface\"\n              data-decision={decision}\n              aria-labelledby={headingId}\n              tabIndex={-1}\n            >\n              <div className=\"cr-diff-review__hunk-bar\">\n                <HunkHeading id={headingId} className=\"cr-diff-review__hunk-title\">\n                  Hunk {i + 1} of {hunks.length}\n                  <span className=\"cr-diff-review__hunk-header\" aria-hidden=\"true\">\n                    {hunk.header}\n                  </span>\n                </HunkHeading>\n                {readOnly ? (\n                  <Badge status={decision === 'accepted' ? 'done' : decision === 'rejected' ? 'failed' : 'waiting'}>\n                    {decision === 'accepted' ? 'Applied' : decision === 'rejected' ? 'Rejected' : 'Not reviewed'}\n                  </Badge>\n                ) : (\n                  <span className=\"cr-diff-review__hunk-actions\">\n                    <Button\n                      size=\"sm\"\n                      aria-pressed={decision === 'accepted'}\n                      aria-keyshortcuts=\"Y\"\n                      onClick={() => decide(hunk.id, 'accepted')}\n                      data-choice=\"accept\"\n                    >\n                      <CheckIcon /> Accept\n                    </Button>\n                    <Button\n                      size=\"sm\"\n                      aria-pressed={decision === 'rejected'}\n                      aria-keyshortcuts=\"N\"\n                      onClick={() => decide(hunk.id, 'rejected')}\n                      data-choice=\"reject\"\n                    >\n                      <CrossIcon /> Reject\n                    </Button>\n                  </span>\n                )}\n              </div>\n              <div className=\"cr-diff-review__lines\">\n                {hunk.lines.map((line, j) => (\n                  <DiffLine key={j} {...line} />\n                ))}\n              </div>\n            </section>\n          );\n        })}\n      </div>\n\n      <footer className=\"cr-diff-review__bar cr-glass\">\n        {readOnly ? (\n          <span>\n            Applied {accepted.length} of {hunks.length} hunks\n          </span>\n        ) : (\n          <>\n            <span className=\"cr-diff-review__progress\">\n              {decided} of {hunks.length} reviewed\n              <KeyboardHint keys={['J']} label=\"next\" />\n              <KeyboardHint keys={['K']} label=\"previous\" />\n              <KeyboardHint keys={['Y']} label=\"accept\" />\n              <KeyboardHint keys={['N']} label=\"reject\" />\n            </span>\n            <span className=\"cr-diff-review__bar-actions\">\n              <Button size=\"sm\" variant=\"ghost\" onClick={() => decideAll('rejected')}>\n                Reject all\n              </Button>\n              <Button size=\"sm\" onClick={() => decideAll('accepted')}>\n                Accept all\n              </Button>\n              <Button size=\"sm\" variant=\"primary\" disabled={decided < hunks.length} onClick={apply}>\n                Apply {accepted.length} of {hunks.length}\n              </Button>\n            </span>\n          </>\n        )}\n      </footer>\n      <LiveRegion message={message} />\n    </section>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/creance/diff-review/DiffReview.tsx"
    }
  ],
  "categories": [
    "organism",
    "agent"
  ],
  "type": "registry:ui"
}