> Premium standalone component (part of no design system, works in any React + Tailwind app) from AI Canvas. Index of every component and install setup: https://aicanvas.me/llms.txt

# Diff Review

Review an agent's proposed code edits change by change: read the reason, keep or discard each one, then apply only what you kept.

- Category: AI Agent UI
- Built with: Motion, Tailwind CSS
- License: Premium (proprietary, not in the public repo)
- Page: https://aicanvas.me/components/change-review

## Install

```bash
npx shadcn@latest add @aicanvas/change-review
```

Requires Premium. Set your AI Canvas token as AICANVAS_TOKEN and register the @aicanvas registry with it, as described in the Install section of https://aicanvas.me/llms.txt. Without a Premium token the command writes a placeholder file instead of the component.

AI agents can also get its install command from the read-only AI Canvas MCP server (`npx -y @aicanvas/mcp`) and run it. Setup: https://aicanvas.me/mcp

Peer dependencies:

```bash
npm install framer-motion
```

## Props

### DiffLine

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `kind` | `DiffLineKind` |  | Selects context, addition, deletion or folded-line rendering. |
| `text` | `string` |  | Line text, or newline-joined hidden lines when kind is fold. |
| `oldNo?` | `number` |  | First old-file line number shown for the line or fold; left out, the old side is blank. |
| `newNo?` | `number` |  | First new-file line number shown for the line or fold; left out, the new side is blank. |
| `marks?` | `[number, number][]` |  | Zero-based text ranges emphasized within the line; left out, no text is emphasized. |
| `count?` | `number` |  | Number of hidden lines represented by a fold; left out, the fold derives its count from the hidden text. |

### DiffHunk

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` |  | Stable hunk identifier that must be unique across all hunks in the files list. |
| `reason` | `string` |  | Explanation shown above the hunk decision controls. |
| `lines` | `DiffLine[]` |  | Diff lines rendered for the hunk. |

### DiffFile

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `path` | `string` |  | File path used as the collapsible file heading. |
| `hunks` | `DiffHunk[]` |  | Reviewable changes contained in the file. |

### ChangeReview

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `title?` | `string` |  | Sets the review heading; left out, the demo uses its sample title and host mode shows an empty heading, while passing it turns the demo off. |
| `checkSummary?` | `string` |  | Shows a check result below the heading; left out, the demo uses its sample result and host mode shows none, while passing it turns the demo off. |
| `files?` | `DiffFile[]` |  | Supplies the files and hunks to review; left out, the demo uses sample changes, while passing it turns the demo off. |
| `onDecide?` | `(hunkId: string, decision: HunkDecision \| null) => void` |  | Reports keep, discard and cleared decisions while decisions are still tracked locally; left out, no decision updates are reported. |
| `onApply?` | `(keptIds: string[]) => void` |  | Receives the kept hunk ids and makes the Apply control available outside the demo; left out, host reviews have no Apply control. |
| `onUndo?` | `() => void` |  | Handles undoing an applied review and makes the receipt Undo control available outside the demo; left out, host receipts have no Undo control. |
| `className?` | `string` |  | Adds classes to the outermost root; left out, only the built-in classes are used. |
| `tuning?` | `Partial<Tuning>` |  | Overrides review measurements, timing and motion values; left out, every value in TUNING is used. |

## About

Change Review shows a code change the agent proposes as a diff per file, with Accept and Reject on every hunk and Apply once each hunk is decided. Changed words are highlighted inside changed lines so a one word edit is obvious, file tabs carry added and removed counts with a dot for undecided hunks, and long unchanged stretches fold away. J and K move between hunks, A and R decide them, arrows switch files. Apply collapses the panel into a one line receipt with a five second undo. Files, hunks and decisions are props; without them a demo reviews and applies its own change.

Use cases: AI coding agents, Code review tools, Pull request assistants.

## Source

The source is Premium and is not published. Preview it at https://aicanvas.me/components/change-review.

## Remix prompt

The public part of the remix prompt: 1. Setup and 2. Constants. The rest of the build spec (state, markup, how it works) is part of Premium.

````markdown
# Change Review
A diff review card where every hunk is kept or discarded before the kept changes apply.

## 1. Setup
```tsx
// npm install framer-motion
```
File: components-workspace/change-review/index.tsx  ·  'use client'  ·  export default function ChangeReview(props: ChangeReviewProps)
```tsx
import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react'
import type * as React from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { RotateCcw } from '@/app/_icons/icons/rotate-ccw'
import { FoldVertical } from '@/app/_icons/icons/fold-vertical'
import { UnfoldVertical } from '@/app/_icons/icons/unfold-vertical'
import { ChevronDown } from '@/app/_icons/icons/chevron-down'
import { Check } from '@/app/_icons/icons/check'
import { X } from '@/app/_icons/icons/x'
import { CodeDuotone } from '@/app/_icons/icons/code-duotone'
import { FileCodeDuotone } from '@/app/_icons/icons/file-code-duotone'
import { CircleCheckDuotone } from '@/app/_icons/icons/circle-check-duotone'
import { CircleMinusDuotone } from '@/app/_icons/icons/circle-minus-duotone'
```
The `@/app/_icons/icons/*` imports are this kit's icon set (props: `size`, `weight`, `className`). Without it, map each import to the matching glyph of any icon set with the same props. Classes need Tailwind CSS v4 (`bg-(--var)`, `@container`, `pointer-coarse:`); the `dark:` variants follow whatever dark mode your project configures.

## 2. Constants
```tsx
'use client'

// Every number the component animates or measures with: ms unless named, sizes in px.
export type Tuning = {
  hoverMs: number
  pressScale: number
  stateMs: number
  enterMs: number
  enterY: number
  staggerMs: number
  exitMs: number
  collapseMs: number
  foldMs: number
  lightMs: number
  cardMaxW: number
  cardRadius: number
  lineH: number
  gutterW: number
  fadeW: number
  replayDelayMs: number
  glyphW: number
  decisionW: number
  dimPct: number
}

export const TUNING: Tuning = {
  hoverMs: 150,
  pressScale: 0.97,
  stateMs: 200,
  enterMs: 280,
  enterY: 6,
  staggerMs: 40,
  exitMs: 120,
  collapseMs: 280,
  foldMs: 280,
  lightMs: 600,
  cardMaxW: 560,
  cardRadius: 24,
  lineH: 20,
  gutterW: 28,
  fadeW: 24,
  // Must equal exitMs + collapseMs, so Replay shows once the receipt has settled.
  replayDelayMs: 400,
  glyphW: 16,
  // Holds the Keep / Discard pair's width once decided, so the reason never reflows.
  decisionW: 176,
  dimPct: 40,
}

const EASE = [0.23, 1, 0.32, 1] as const

export type DiffLineKind = 'ctx' | 'add' | 'del' | 'fold'

// fold: text is the hidden lines joined by newlines; oldNo / newNo number the first.
export type DiffLine = {
  kind: DiffLineKind
  text: string
  oldNo?: number
  newNo?: number
  marks?: [number, number][]
  count?: number
}

export type DiffHunk = { id: string; reason: string; lines: DiffLine[] }

export type DiffFile = { path: string; hunks: DiffHunk[] }

export type HunkDecision = 'keep' | 'discard'

type ChangeReviewProps = {
  title?: string
  checkSummary?: string
  files?: DiffFile[]
  onDecide?: (hunkId: string, decision: HunkDecision | null) => void
  onApply?: (keptIds: string[]) => void
  onUndo?: () => void
  className?: string
  tuning?: Partial<Tuning>
}

function marksOf(text: string, ...parts: string[]): [number, number][] {
  return parts.map((p) => {
    const at = text.indexOf(p)
    return [at, at + p.length]
  })
}

const A_DEL = String.raw`  const d = new Date(input.replace(/Z|[+-]\d\d:\d\d$/, ''))`

const B_DEL = "  if (!input) throw new Error('empty date')"

const B_ADD = '  if (!input.trim()) return null'

const DEMO_FILES: DiffFile[] = [
  {
    path: 'src/date.ts',
    hunks: [
      {
        id: 'offset',
        reason: 'Keep the time zone offset when parsing ISO strings',
        lines: [
          { kind: 'ctx', text: 'export function parseDate(input: string) {', oldNo: 1, newNo: 1 },
          { kind: 'del', text: A_DEL, oldNo: 2, marks: marksOf(A_DEL, String.raw`.replace(/Z|[+-]\d\d:\d\d$/, '')`) },
          { kind: 'add', text: '  const d = new Date(input)', newNo: 2 },
          { kind: 'ctx', text: '  if (Number.isNaN(d.getTime())) return null', oldNo: 3, newNo: 3 },
        ],
      },
      {
        id: 'empty',
        reason: 'Return null for an empty string instead of throwing',
        lines: [
          {
            kind: 'fold',
            oldNo: 4,
            newNo: 4,
            count: 12,
            text: [
              '  return d',
              '}',
              '',
              'export function toDay(date: Date) {',
              '  return date.toISOString().slice(0, 10)',
              '}',
              '',
              'export function isSameDay(a: Date, b: Date) {',
              '  return toDay(a) === toDay(b)',
              '}',
              '',
              '// Accepts YYYY-MM-DD and returns midnight UTC.',
            ].join('\n'),
          },
          { kind: 'ctx', text: 'export function parseDay(input: string) {', oldNo: 16, newNo: 16 },
          { kind: 'del', text: B_DEL, oldNo: 17, marks: marksOf(B_DEL, "throw new Error('empty date')") },
          { kind: 'add', text: B_ADD, newNo: 17, marks: marksOf(B_ADD, '.trim()', 'return null') },
          { kind: 'ctx', text: "  return parseDate(input + 'T00:00:00Z')", oldNo: 18, newNo: 18 },
        ],
      },
    ],
  },
  {
    path: 'src/date.test.ts',
    hunks: [
      {
        id: 'test',
        reason: 'Add a test for a +09:00 offset',
        lines: [
          {
            kind: 'ctx',
            text: "  it('returns null for an invalid date', () => expect(parseDate('nope')).toBeNull())",
            oldNo: 14,
            newNo: 14,
          },
          { kind: 'add', text: "  it('keeps a +09:00 offset', () => {", newNo: 15 },
          { kind: 'add', text: "    const d = parseDate('2026-03-01T09:00:00+09:00')", newNo: 16 },
          { kind: 'add', text: "    expect(d?.toISOString()).toBe('2026-03-01T00:00:00.000Z')", newNo: 17 },
          { kind: 'add', text: '  })', newNo: 18 },
          { kind: 'ctx', text: '})', oldNo: 15, newNo: 19 },
        ],
      },
    ],
  },
]

const DEFAULT_TITLE = 'Fix the time zone offset in parseDate'

const DEFAULT_CHECK = 'Tests pass · 58 of 58'

const BLOCKED = 'Review every change first'

const NBSP = String.fromCharCode(160)

function plural(n: number, one: string, many = `${one}s`) {
  return `${n} ${n === 1 ? one : many}`
}

function countLines(lines: DiffLine[]) {
  let add = 0
  let del = 0
  for (const l of lines) {
    if (l.kind === 'add') add++
    else if (l.kind === 'del') del++
  }
  return { add, del }
}

function countFile(file: DiffFile) {
  return countLines(file.hunks.flatMap((h) => h.lines))
}

function splitLeading(lines: DiffLine[]) {
  let i = 0
  while (i < lines.length && lines[i].kind === 'fold') i++
  return { lead: lines.slice(0, i), body: lines.slice(i) }
}

function unfold(line: DiffLine): DiffLine[] {
  const rows = line.text.split('\n')
  return rows.map((text, i) => ({
    kind: 'ctx',
    text,
    oldNo: line.oldNo === undefined ? undefined : line.oldNo + i,
    newNo: line.newNo === undefined ? undefined : line.newNo + i,
  }))
}

function foldCount(line: DiffLine) {
  return line.count ?? line.text.split('\n').length
}

function maxLineNo(files: DiffFile[]) {
  let max = 0
  for (const f of files) {
    for (const h of f.hunks) {
      for (const l of h.lines) {
        const span = l.kind === 'fold' ? foldCount(l) - 1 : 0
        max = Math.max(max, (l.oldNo ?? 0) + span, (l.newNo ?? 0) + span)
      }
    }
  }
  return max
}

type TokenKind = 'keyword' | 'string' | 'number' | 'comment' | 'ident' | 'fn' | 'punct'

type Token = { text: string; kind: TokenKind }

type ScanState = { block: boolean; template: boolean }

const KEYWORDS = new Set([
  'abstract', 'any', 'as', 'async', 'await', 'bigint', 'boolean', 'break', 'case', 'catch',
  'class', 'const', 'continue', 'declare', 'default', 'delete', 'do', 'else', 'enum', 'export',
  'extends', 'finally', 'for', 'from', 'function', 'if', 'implements', 'import', 'in', 'infer',
  'instanceof', 'interface', 'is', 'keyof', 'let', 'never', 'new', 'number', 'object', 'of',
  'private', 'protected', 'public', 'readonly', 'return', 'satisfies', 'static', 'string',
  'super', 'switch', 'symbol', 'this', 'throw', 'try', 'type', 'typeof', 'unknown', 'var',
  'void', 'while', 'yield',
])

const LITERALS = new Set(['false', 'Infinity', 'NaN', 'null', 'true', 'undefined'])

function closingQuote(line: string, from: number, quote: string) {
  for (let i = from; i < line.length; i += 1) {
    if (line[i] === '\\') {
      i += 1
      continue
    }
    if (line[i] === quote) return i
  }
  return -1
}

function scanLine(line: string, state: ScanState, out: Token[]) {
  let i = 0
  while (i < line.length) {
    if (state.block || state.template) {
      const end = state.block ? line.indexOf('*/', i) : closingQuote(line, i, '`')
      const kind: TokenKind = state.block ? 'comment' : 'string'
      if (end === -1) {
        out.push({ text: line.slice(i), kind })
        return
      }
      const stop = state.block ? end + 2 : end + 1
      out.push({ text: line.slice(i, stop), kind })
      state.block = false
      state.template = false
      i = stop
      continue
    }
    const ch = line[i]
    if (ch === '/' && line[i + 1] === '/') {
      out.push({ text: line.slice(i), kind: 'comment' })
      return
    }
    if (ch === '/' && line[i + 1] === '*') {
      state.block = true
      out.push({ text: '/*', kind: 'comment' })
      i += 2
      continue
    }
    if (ch === '"' || ch === "'" || ch === '`') {
      const end = closingQuote(line, i + 1, ch)
      if (end === -1) {
        out.push({ text: line.slice(i), kind: 'string' })
        if (ch === '`') state.template = true
        return
      }
      out.push({ text: line.slice(i, end + 1), kind: 'string' })
      i = end + 1
      continue
    }
    if (/[0-9]/.test(ch)) {
      let j = i + 1
      while (j < line.length && /[0-9a-fA-FxXoObBn._]/.test(line[j])) j += 1
      out.push({ text: line.slice(i, j), kind: 'number' })
      i = j
      continue
    }
    if (/[A-Za-z_$]/.test(ch)) {
      let j = i + 1
      while (j < line.length && /[A-Za-z0-9_$]/.test(line[j])) j += 1
      const word = line.slice(i, j)
      let kind: TokenKind = 'ident'
      if (KEYWORDS.has(word)) kind = 'keyword'
      else if (LITERALS.has(word)) kind = 'number'
      else if (line[j] === '(' && /^[a-z_$]/.test(word)) kind = 'fn'
      out.push({ text: word, kind })
      i = j
      continue
    }
    let j = i + 1
    while (j < line.length && !/[A-Za-z0-9_$"'`/]/.test(line[j])) j += 1
    out.push({ text: line.slice(i, j), kind: 'punct' })
    i = j
  }
}

// Lines are scanned one by one, so a multi-line string or comment colours only its first.
function tokenizeLine(line: string): Token[] {
  const out: Token[] = []
  scanLine(line, { block: false, template: false }, out)
  return out
}

const TOKEN_CLASS: Record<TokenKind, string> = {
  keyword: 'text-(color:--cr-ai)',
  string: 'text-(color:--cr-success)',
  number: 'text-(color:--cr-attention)',
  comment: 'italic text-(color:--cr-muted)',
  ident: 'text-(color:--cr-text)',
  fn: 'font-semibold text-(color:--cr-text)',
  punct: 'text-(color:--cr-body)',
}

const THEME_VARS = [
  '[--cr-page:#EEEEF3] dark:[--cr-page:#0E0E0F]',
  '[--cr-panel:#F4F4FA] dark:[--cr-panel:#1B1B1C]',
  '[--cr-well:#EEEEF3] dark:[--cr-well:#0E0E0F]',
  '[--cr-raised:#DFDFE3] dark:[--cr-raised:#2D2D2E]',
  '[--cr-raised-hover:#CACACD] dark:[--cr-raised-hover:#373738]',
  '[--cr-border:#DFDFE3] dark:[--cr-border:#2D2D2E]',
  '[--cr-border-strong:#CACACD] dark:[--cr-border-strong:#373738]',
  '[--cr-hairline:#DFDFE3] dark:[--cr-hairline:#2D2D2E]',
  '[--cr-rim:#CACACD] dark:[--cr-rim:#F4F4FA2E]',
  '[--cr-text:#1B1B1C] dark:[--cr-text:#F4F4FA]',
  '[--cr-body:#373738] dark:[--cr-body:#CACACD]',
  '[--cr-muted:#575759] dark:[--cr-muted:#9B9B9E]',
  '[--cr-faint:#7B7B7D]',
  '[--cr-selection:#A0A0A233] dark:[--cr-selection:#5E5E6333]',
  '[--cr-success:#047857] dark:[--cr-success:#34D399]',
  '[--cr-ai:#0369A1] dark:[--cr-ai:#7DD3FC]',
  '[--cr-attention:#B45309] dark:[--cr-attention:#FFB574]',
  '[--cr-red:#C8262C] dark:[--cr-red:#E5484D]',
  '[--cr-new:#6D28D9] dark:[--cr-new:#B79CFF]',
  '[--cr-add-bg:color-mix(in_srgb,var(--cr-success)_13%,transparent)] dark:[--cr-add-bg:color-mix(in_srgb,var(--cr-success)_10%,transparent)]',
  '[--cr-add-word:color-mix(in_srgb,var(--cr-success)_26%,transparent)] dark:[--cr-add-word:color-mix(in_srgb,var(--cr-success)_24%,transparent)]',
  '[--cr-del-bg:color-mix(in_srgb,var(--cr-red)_8%,transparent)] dark:[--cr-del-bg:color-mix(in_srgb,var(--cr-red)_12%,transparent)]',
  '[--cr-del-word:color-mix(in_srgb,var(--cr-red)_18%,transparent)] dark:[--cr-del-word:color-mix(in_srgb,var(--cr-red)_28%,transparent)]',
  '[--cr-gutter:color-mix(in_srgb,var(--cr-muted)_75%,var(--cr-faint))]',
  '[--cr-scroll:var(--cr-hairline)]',
  '[--cr-primary:#1B1B1C] dark:[--cr-primary:#F4F4FA]',
  '[--cr-primary-hover:#373738] dark:[--cr-primary-hover:#FFFFFF]',
  '[--cr-on-primary:#F4F4FA] dark:[--cr-on-primary:#0E0E0F]',
  '[--cr-focus:#1B1B1C99] dark:[--cr-focus:#F4F4FA99]',
  '[--cr-focus-gap:#F4F4FA] dark:[--cr-focus-gap:#1B1B1C]',
  '[--cr-dot:#1B1B1C] dark:[--cr-dot:#F4F4FA]',
  '[--cr-elev-card:0_2px_6px_rgba(0,0,0,0.06),0_28px_56px_-16px_rgba(0,0,0,0.25)] dark:[--cr-elev-card:0_2px_6px_rgba(0,0,0,0.5),0_36px_72px_-18px_rgba(0,0,0,0.9)]',
  '[--cr-elev-1:0_1px_2px_rgba(0,0,0,0.08)] dark:[--cr-elev-1:0_1px_2px_rgba(0,0,0,0.5)]',
  '[--cr-elev-menu:0_4px_12px_rgba(0,0,0,0.10),0_16px_32px_-8px_rgba(0,0,0,0.2)] dark:[--cr-elev-menu:0_4px_12px_rgba(0,0,0,0.5),0_16px_32px_-8px_rgba(0,0,0,0.8)]',
].join(' ')

const FOCUS =
  'outline-none focus-visible:ring-2 focus-visible:ring-(color:--cr-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(color:--cr-focus-gap)'

// Inside a clipped surface an offset ring would be cut, so it draws inward.
const FOCUS_INSET =
  'outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-(color:--cr-focus)'

// Drawn as an overlay above the sticky gutter, which would otherwise paint over the ring.
const FOCUS_HUNK =
  "outline-none focus-visible:after:pointer-events-none focus-visible:after:absolute focus-visible:after:inset-0 focus-visible:after:z-[2] focus-visible:after:rounded-[inherit] focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-(color:--cr-focus) focus-visible:after:content-['']"

// Touch targets: the box stays as drawn, a pseudo-element grows the hit area to 44px.
const HIT = "relative pointer-coarse:after:absolute pointer-coarse:after:content-['']"

// 32px buttons 8px apart: 6px up and down reaches 44, 4px a side tiles the gap.
const HIT_SM = `${HIT} pointer-coarse:after:-inset-y-1.5 pointer-coarse:after:-inset-x-1`

// A lone 32px button: 6px on every side reaches 44 both ways.
const HIT_ICON = `${HIT} pointer-coarse:after:-inset-1.5`

// The 28px fold row grows 8px into each neighbouring gap and stops at its edges.
const HIT_FOLD = `${HIT} pointer-coarse:after:-inset-y-2 pointer-coarse:after:inset-x-0`

// Size and look are separate pieces, so no two classes fight over one property.
const BTN = `${FOCUS} inline-flex shrink-0 items-center justify-center gap-1.5 rounded-full font-semibold transition-[background-color,color,border-color,opacity] duration-150`

const SM = 'h-8 px-4 text-[12px] leading-4'

const SM_TIGHT = 'h-8 px-3 text-[12px] leading-4'

const PRIMARY = 'bg-(--cr-primary) text-(color:--cr-on-primary) shadow-(--cr-elev-1)'

const SECONDARY = 'border border-(color:--cr-border-strong) text-(color:--cr-text) hover:bg-(--cr-raised)'

const GHOST = 'text-(color:--cr-muted) hover:bg-(--cr-raised) hover:text-(color:--cr-text)'

const TITLE = 'min-w-0 flex-1 text-[15px] leading-6 font-semibold text-(color:--cr-text) [overflow-wrap:anywhere]'

const META = 'shrink-0 text-[12px] leading-6 tabular-nums text-(color:--cr-muted)'

const WELL = 'rounded-2xl border border-(color:--cr-border) bg-(--cr-well)'

// Chrome ignores the ::-webkit pseudo-elements while the standard properties are set.
const SCROLLBAR =
  '[scrollbar-color:auto] [scrollbar-width:auto] [&::-webkit-scrollbar]:size-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-(--cr-scroll) [&::-webkit-scrollbar-corner]:bg-transparent supports-[-moz-appearance:none]:[scrollbar-color:var(--cr-scroll)_transparent] supports-[-moz-appearance:none]:[scrollbar-width:thin]'

const FADE_END = 'linear-gradient(to bottom, black calc(100% - 40px), transparent)'

type Tone = 'neutral' | 'ai' | 'attention' | 'success' | 'red'

const TONE_VAR: Record<Tone, string> = {
  neutral: 'var(--cr-dot)',
  ai: 'var(--cr-ai)',
  attention: 'var(--cr-attention)',
  success: 'var(--cr-success)',
  red: 'var(--cr-red)',
}

const TONE_STRENGTH: Record<Tone, number> = { neutral: 6, ai: 14, attention: 14, success: 10, red: 12 }

const REDUCED_QUERY = '(prefers-reduced-motion: reduce)'

function subscribeReduced(onChange: () => void) {
  if (typeof window === 'undefined' || !window.matchMedia) return () => {}
  const mq = window.matchMedia(REDUCED_QUERY)
  mq.addEventListener('change', onChange)
  return () => mq.removeEventListener('change', onChange)
}

function getReduced() {
  return typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia(REDUCED_QUERY).matches
}

function getReducedServer() {
  return false
}

type Phase = 'review' | 'receipt'

// noScroll: the target is in view, or a mid-resize scroll would shift the card's list.
type FocusRequest = { key: string; noScroll: boolean }

type ReviewProps = {
  title: string
  checkSummary: string
  files: DiffFile[]
  decisions: Record<string, HunkDecision>
  collapsed: Record<string, boolean>
  folds: Record<string, boolean>
  left: number
  keptCount: number
  allReviewed: boolean
  rise: boolean
  reduce: boolean
  t: Tuning
  onMounted: () => void
  onDecide: (id: string, d: HunkDecision | null, viaKeyboard: boolean) => void
  onToggleFile: (path: string) => void
  onToggleFold: (key: string) => void
  onKeepAll: (viaKeyboard: boolean) => void
  onApply?: () => void
  keyHints: string
}

function beatsFor(files: DiffFile[]) {
  let n = 1
  const out = files.map((file) => ({
    file: n++,
    hunks: file.hunks.map((h) => ({
      lead: splitLeading(h.lines).lead.length > 0 ? n++ : -1,
      hunk: n++,
    })),
  }))
  return { files: out, footer: n }
}

type Enter = {
  initial: false | { opacity: number; y?: number }
  animate: { opacity: number; y: number }
  transition: { duration: number; ease: typeof EASE; delay: number }
}

const GLIDE = 'transition-[color,background-color,text-decoration-color] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]'

function sliceTokens(tokens: Token[], from: number, to: number) {
  const out: Token[] = []
  let at = 0
  for (const tok of tokens) {
    const end = at + tok.text.length
    if (end > from && at < to) {
      out.push({ text: tok.text.slice(Math.max(from - at, 0), Math.min(to, end) - at), kind: tok.kind })
    }
    at = end
    if (at >= to) break
  }
  return out
}

/**
 * @typedef {object} DiffLine
 * @property {DiffLineKind} kind Selects context, addition, deletion or folded-line rendering.
 * @property {string} text Line text, or newline-joined hidden lines when kind is fold.
 * @property {number} [oldNo] First old-file line number shown for the line or fold; left out, the old side is blank.
 * @property {number} [newNo] First new-file line number shown for the line or fold; left out, the new side is blank.
 * @property {[number, number][]} [marks] Zero-based text ranges emphasized within the line; left out, no text is emphasized.
 * @property {number} [count] Number of hidden lines represented by a fold; left out, the fold derives its count from the hidden text.
 */

/**
 * @typedef {object} DiffHunk
 * @property {string} id Stable hunk identifier that must be unique across all hunks in the files list.
 * @property {string} reason Explanation shown above the hunk decision controls.
 * @property {DiffLine[]} lines Diff lines rendered for the hunk.
 */

/**
 * @typedef {object} DiffFile
 * @property {string} path File path used as the collapsible file heading.
 * @property {DiffHunk[]} hunks Reviewable changes contained in the file.
 */

/**
 * @typedef {object} ChangeReviewProps
 * @property {string} [title] Sets the review heading; left out, the demo uses its sample title and host mode shows an empty heading, while passing it turns the demo off.
 * @property {string} [checkSummary] Shows a check result below the heading; left out, the demo uses its sample result and host mode shows none, while passing it turns the demo off.
 * @property {DiffFile[]} [files] Supplies the files and hunks to review; left out, the demo uses sample changes, while passing it turns the demo off.
 * @property {(hunkId: string, decision: HunkDecision | null) => void} [onDecide] Reports keep, discard and cleared decisions while decisions are still tracked locally; left out, no decision updates are reported.
 * @property {(keptIds: string[]) => void} [onApply] Receives the kept hunk ids and makes the Apply control available outside the demo; left out, host reviews have no Apply control.
 * @property {() => void} [onUndo] Handles undoing an applied review and makes the receipt Undo control available outside the demo; left out, host receipts have no Undo control.
 * @property {string} [className] Adds classes to the outermost root; left out, only the built-in classes are used.
 * @property {Partial<Tuning>} [tuning] Overrides review measurements, timing and motion values; left out, every value in TUNING is used.
 */
```
````

## Related

- [Code Block](https://aicanvas.me/components/code-panel.md): Code an agent just wrote streams in line by line, then numbered markers tie its notes to the exact lines, with Copy and Apply.
- [Permission Request](https://aicanvas.me/components/permission-scope.md): A permission card where an agent asks for access and you allow it once, for this chat or always for a pattern, then revoke it from the receipt.
- [Agent Status](https://aicanvas.me/components/agent-status.md): A status pill that says what the agent is doing, whose move it is, time and cost, and opens to the step history and token bill.
- [Agent Plan](https://aicanvas.me/components/plan-runner.md): An agent's step plan you can edit and start, then watch run one step at a time with live activity and a segmented progress bar.
- [Agent Composer](https://aicanvas.me/components/agent-composer.md): The message input an agent sits behind: a textarea that grows to six rows, inline slash commands, @file mentions as removable chips, a drag-and-drop attachment row, a model picker with each provider's logo, and a round send button whose glyph becomes a pulsing stop square while the agent works.
