# Diff Apply: Animated Code Diff for React

> A code change that applies itself in place. Removed lines strike through and close up, new lines open in tinted, and the line numbers below roll to their new values. Each change then waits for Keep or Undo, and Undo plays it backwards.

Animated Code Diff for React: a code change applies itself, with Keep and Undo. Built with Tailwind CSS and Motion; install it with the shadcn CLI. It's a KeyframeUI component: the file lands in your project as source, yours to edit.

- Docs and live preview: https://keyframeui.com/components/diff-apply
- Tags: Code, Developer, AI
- npm dependencies: motion, clsx, tailwind-merge

## Installation

Register the @keyframeui registry in `components.json` once per project:

```json
{
  "registries": {
    "@keyframeui": "https://keyframeui.com/r/{name}.json"
  }
}
```

Then add the component. It lands in `components/ui/diff-apply.tsx`, and the CLI also adds `lib/utils.ts` (the `cn` helper) and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/diff-apply
```

Or install by URL, without registering the namespace:

```bash
npx shadcn@latest add https://keyframeui.com/r/diff-apply.json
```

## Usage

The main demo shows typical usage.

### Agent Refactor

An assistant wraps an invoice fetch in a retry with backoff: an import, a doc comment and the new body land one after another. Keep or undo each change, or all of them from the header.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/diff-apply-demo
```

```tsx title="components/diff-apply-demo.tsx"
"use client";

import { DiffApply } from "@/components/ui/diff-apply";

const before = `
import type { Invoice } from "./types";

const API = "https://api.northwind.dev/v2";

export async function fetchInvoice(id: string): Promise<Invoice> {
  const res = await fetch(\`\${API}/invoices/\${id}\`);
  if (!res.ok) {
    throw new Error("Request failed");
  }
  return res.json();
}

export function formatTotal(invoice: Invoice) {
  return new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: invoice.currency,
  }).format(invoice.total / 100);
}
`;

const after = `
import type { Invoice } from "./types";
import { retry } from "@/lib/retry";

const API = "https://api.northwind.dev/v2";

/** Retries 5xx responses up to 3 times, backing off from 250ms. */
export async function fetchInvoice(id: string): Promise<Invoice> {
  return retry(
    async () => {
      const res = await fetch(\`\${API}/invoices/\${id}\`);
      if (res.status >= 500) throw new Error(\`Northwind \${res.status}\`);
      if (!res.ok) throw new Error(\`Invoice \${id} not found\`);
      return res.json();
    },
    { attempts: 3, backoff: (n) => 250 * 2 ** n },
  );
}

export function formatTotal(invoice: Invoice) {
  return new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: invoice.currency,
  }).format(invoice.total / 100);
}
`;

export function DiffApplyDemo() {
  return (
    <div className="flex w-full max-w-2xl flex-col gap-3">
      <div className="flex items-center gap-2.5 px-1 text-[13px] text-zinc-600 dark:text-zinc-400">
        <span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-zinc-900 text-white dark:bg-white dark:text-zinc-900">
          <svg aria-hidden viewBox="0 0 16 16" className="size-3.5" fill="currentColor">
            <path d="M8 1.5c.3 2.9 1.6 4.2 4.5 4.5-2.9.3-4.2 1.6-4.5 4.5-.3-2.9-1.6-4.2-4.5-4.5 2.9-.3 4.2-1.6 4.5-4.5Zm4.25 7.25c.15 1.45.8 2.1 2.25 2.25-1.45.15-2.1.8-2.25 2.25-.15-1.45-.8-2.1-2.25-2.25 1.45-.15 2.1-.8 2.25-2.25Z" />
          </svg>
        </span>
        <p className="min-w-0 truncate">
          <span className="font-medium text-zinc-900 dark:text-zinc-100">Retry invoice fetches</span> when Northwind
          returns a 5xx, with backoff
        </p>
      </div>
      <DiffApply filename="billing/fetch-invoice.ts" before={before} after={after} />
    </div>
  );
}
```

## Examples

Finished sections built with Diff Apply. Each one installs whole, with the component included.

### Codemod Upgrade

A framework upgrade rewrites a config file in four small changes. The box is capped with `maxHeight`, so it scrolls each change into view as it lands.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/diff-apply-migration-demo
```

```tsx title="components/diff-apply-migration-demo.tsx"
"use client";

import { DiffApply } from "@/components/ui/diff-apply";

const before = `
import { defineConfig } from "harbor/config";
import tailwind from "@harbor/tailwind";
import { sentry } from "@harbor/sentry";

export default defineConfig({
  name: "field-and-fold",
  target: "node16",
  experimental: {
    edge: true,
    turboBuild: true,
  },
  images: {
    domains: ["images.unsplash.com", "cdn.fieldandfold.co"],
  },
  plugins: [
    tailwind(),
    sentry({ dsn: process.env.SENTRY_DSN }),
  ],
  headers: async () => [
    { source: "/(.*)", headers: [{ key: "X-Frame-Options", value: "DENY" }] },
  ],
  legacyBrowsers: true,
});
`;

const after = `
import { defineConfig } from "harbor";
import tailwind from "@harbor/tailwind";
import { sentry } from "@harbor/sentry";

export default defineConfig({
  name: "field-and-fold",
  target: "node20",
  runtime: "edge",
  images: {
    remotePatterns: [
      { hostname: "images.unsplash.com" },
      { hostname: "cdn.fieldandfold.co" },
    ],
  },
  plugins: [
    tailwind(),
    sentry({ dsn: process.env.SENTRY_DSN }),
  ],
  headers: async () => [
    { source: "/(.*)", headers: [{ key: "X-Frame-Options", value: "DENY" }] },
  ],
});
`;

export function DiffApplyMigrationDemo() {
  return (
    <div className="flex w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-zinc-950/10 bg-zinc-950 shadow-sm dark:border-white/10">
      <div className="flex items-center gap-2 px-4 py-3 font-mono text-[12.5px] text-zinc-400">
        <span className="text-emerald-400">$</span>
        <span className="text-zinc-100">npx harbor upgrade 4</span>
        <span className="ml-auto hidden text-zinc-500 sm:inline">2 files updated · 1 to review</span>
      </div>
      <div className="p-1.5 pt-0">
        <DiffApply
          filename="harbor.config.ts"
          before={before}
          after={after}
          maxHeight="17rem"
          className="rounded-[0.85rem] border-white/10 shadow-none"
        />
      </div>
    </div>
  );
}
```

### Release Note Edit

Not only code: a writing assistant tightens a Markdown release note, with `wrap` on and no line numbers. The reply under it follows `onComplete` and `onReview`.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/diff-apply-copy-demo
```

```tsx title="components/diff-apply-copy-demo.tsx"
"use client";

import * as React from "react";
import { DiffApply, type DiffApplyDecision } from "@/components/ui/diff-apply";

const before = `
## Tinta 2.4

We are really excited to announce that Tinta 2.4 is finally here and available for everyone to use today!

### What's new

- Exports are now a lot faster than they used to be.
- Fixed a bug where some people could sometimes not sign in.
- Dark mode.

Thanks to everyone who sent us feedback!
`;

const after = `
## Tinta 2.4

Tinta 2.4 is out today.

### What's new

- PDF exports finish up to 3× faster.
- Fixed sign-in failing for accounts created with Google.
- Dark mode now follows your system setting.

Thanks to the 412 people who sent feedback during the beta.
`;

export function DiffApplyCopyDemo() {
  const [done, setDone] = React.useState(false);
  const [decisions, setDecisions] = React.useState<DiffApplyDecision[]>([]);
  const kept = decisions.filter((d) => d === "kept").length;
  const undone = decisions.filter((d) => d === "undone").length;
  const reviewed = decisions.length > 0 && decisions.every((d) => d !== "pending");

  return (
    <div className="flex w-full max-w-xl flex-col gap-3">
      <div className="ml-auto max-w-[85%] rounded-2xl rounded-br-md bg-zinc-900 px-3.5 py-2 text-[14px] leading-snug text-white dark:bg-zinc-100 dark:text-zinc-900">
        Tighten this for the changelog. Be specific.
      </div>
      <DiffApply
        filename="release-notes.md"
        before={before}
        after={after}
        wrap
        lineNumbers={false}
        onComplete={() => setDone(true)}
        onReview={setDecisions}
      />
      <p
        aria-live="polite"
        className="min-h-5 px-1 text-[13px] text-zinc-500 transition-opacity duration-300 dark:text-zinc-400"
        style={{ opacity: done ? 1 : 0 }}
      >
        {reviewed
          ? kept === decisions.length
            ? "All three edits kept. Ready to publish."
            : `${kept} kept, ${undone} undone.`
          : "Cut 12 words and named the numbers. Keep what reads right."}
      </p>
    </div>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `before` (required) | `string` |  | The file as it was. A leading blank line and trailing whitespace are trimmed, so template literals read naturally. |
| `after` (required) | `string` |  | The file after the edit. The component works out the changed lines itself (a line diff) and groups them into changes that play one after another. |
| `filename` | `string` |  | Shown in the tab. Its extension picks the highlighting when `language` isn't set. |
| `language` | `string` |  | Highlighting: `ts`/`js` (and `tsx`, `jsx`), `json`, `css` or `md`. Anything else is plain. Defaults to the filename's extension. |
| `review` | `boolean` | `true` | After a change lands, wait for Keep or Undo: a small pill at the change's right edge, plus Undo all and Keep all in the header, usable while later changes still play. Off, the tint fades 1.4s after each change lands and the file reads as finished. |
| `play` | `boolean` |  | Leave unset to play once the block is 40% in view. Set it to drive the block yourself: `true` plays the change, `false` puts the file back to `before`. |
| `replay` | `boolean` | `true` | Show a Replay button in the header once the change has played. It fades the file back to `before` and plays it again. |
| `wrap` | `boolean` | `false` | Wrap long lines instead of scrolling sideways. Use it for prose; the strike follows every wrapped line. |
| `lineNumbers` | `boolean` | `true` | Show the line-number gutter, with `+` and `−` beside changed lines. It stays put while the code scrolls sideways. |
| `maxHeight` | `number \| string` |  | Cap the code box's height, in pixels or any CSS length. The box scrolls inside, and follows each change into view without scrolling the page. |
| `stagger` | `number` | `0.46` | Seconds between one change starting and the next. Files with many changes tighten it, so the whole run stays short. |
| `duration` | `number` | `0.32` | Seconds removed lines take to close and new lines to open, on one spring. Undo plays back a little quicker. |
| `onComplete` | `() => void` |  | Called once every change has landed. |
| `onReview` | `(decisions: DiffApplyDecision[]) => void` |  | Called after each Keep or Undo with the decision for every change, in order: `"pending"`, `"kept"` or `"undone"`. |
| `className` | `string` |  | Classes for the card, e.g. a max width or radius. Colors come from the theme's `background`, `foreground`, `muted` and `border`; the highlighter's hues are one map at the top of the file. |

## What to keep

To adapt it, change its props first. When you do edit `components/ui/diff-apply.tsx`, keep these, which make it feel and behave right:

- The box reserves the taller version's height (the summed `data-ver` rows) and follows each change by scrolling only itself, so nothing on the page moves while the change plays.
- Removed lines close and new lines open on one height spring, so the code below slides as one block; Undo plays it back on the quicker `undo` spring, scaled to `duration`.
- Each timeline step only moves a hunk on from the status it expects, so a Keep or Undo mid-run wins over the timers still pending.
- The strike is a transparent copy of the line showing only its line-through, revealed left to right by `clipPath`, so wrapped lines strike too.
- Reduced motion (read only after hydration) drops the band, strike, fold, roll and scroll: the finished diff appears at once, tinted.

Its timing (durations, delays, springs and easings) is all in the `TIMING` block at the top of the file; the timing props default to it.

## Source

```tsx title="components/ui/diff-apply.tsx"
"use client";

import * as React from "react";
import { AnimatePresence, animate, motion, useReducedMotion, type AnimationPlaybackControls } from "motion/react";

import { cn } from "@/lib/utils";

/**
 * Keep:
 * - The box reserves the taller version's height (the summed `data-ver` rows) and follows each
 *   change by scrolling only itself, so nothing on the page moves while the change plays.
 * - Removed lines close and new lines open on one height spring, so the code below slides as one
 *   block; Undo plays it back on the quicker `undo` spring, scaled to `duration`.
 * - Each timeline step only moves a hunk on from the status it expects, so a Keep or Undo
 *   mid-run wins over the timers still pending.
 * - The strike is a transparent copy of the line showing only its line-through, revealed left to
 *   right by `clipPath`, so wrapped lines strike too.
 * - Reduced motion (read only after hydration) drops the band, strike, fold, roll and scroll: the
 *   finished diff appears at once, tinted.
 */
const TIMING = {
  /** Between one hunk starting and the next, in seconds (the `stagger` prop). */
  stagger: 0.46,
  /** The stagger's floor when a file has many hunks, in seconds. */
  minStagger: 0.26,
  /** The whole run aims to fit in this before the stagger tightens, in seconds. */
  spread: 2.8,
  /** After the run starts, before the first band lands, in seconds. */
  start: 0.08,
  /** The band sitting on a hunk before its lines move, in seconds. */
  lead: 0.12,
  /** A hunk staying tinted when `review` is off, in seconds. */
  settle: 1.4,
  /** The strike drawing across removed lines, and pulling back. */
  strike: { duration: 0.22, ease: [0.65, 0, 0.35, 1] },
  unstrike: { duration: 0.18, ease: "easeOut" },
  /** Lines closing and opening; its `visualDuration` is the `duration` prop, in seconds. */
  fold: { type: "spring", visualDuration: 0.32, bounce: 0 },
  /** Undo, quicker than the edit. */
  undo: { type: "spring", visualDuration: 0.28, bounce: 0 },
  /** A changed line's own fade as it opens and closes. */
  rowIn: { duration: 0.24, ease: "easeOut", delay: 0.04 },
  rowOut: { duration: 0.16, ease: "easeOut" },
  /** The red and green tints, the seam and the header counter fading. */
  tint: { duration: 0.2, ease: "easeOut" },
  /** Struck text dimming, as a Tailwind class. */
  dim: "duration-200",
  /** The band moving to the next hunk, fading in on the first and out after the last. */
  band: { type: "spring", visualDuration: 0.3, bounce: 0 },
  bandIn: { duration: 0.15, ease: "easeOut" },
  bandOut: { duration: 0.18, ease: "easeOut" },
  /** A line number rolling. */
  roll: { type: "spring", visualDuration: 0.3, bounce: 0 },
  /** Between line numbers in the wave, and the wave's cap, in seconds. */
  rollStep: 0.008,
  rollSpread: 0.15,
  /** A hunk's Undo / Keep pill and the header's Undo all / Keep all showing up. */
  pill: { type: "spring", visualDuration: 0.24, bounce: 0 },
  /** The pill leaving, and fading in and out under reduced motion. */
  pillOut: { duration: 0.12, ease: "easeOut" },
  pillReduced: { duration: 0.12 },
  /** The header's Undo all / Keep all leaving. */
  actionsOut: { duration: 0.12 },
  /** The review strip's fade as it opens and closes, in seconds, and its curve. */
  stripFade: { open: 0.2, close: 0.12, ease: "easeOut" },
  /** The box scrolling a hunk into view. */
  scroll: { duration: 0.45, ease: [0.22, 1, 0.36, 1] },
  /** The file fading back to `before` on replay. */
  replay: { duration: 0.15, ease: "easeOut" },
  /** Buttons' hover color and opacity, as a Tailwind class. */
  press: "duration-150",
} as const;

/* ─────────────────────────────────────────────────────────
 * DIFF APPLY STORYBOARD
 *
 *  rest     an editor card: the file's tab, line numbers and the
 *           `before` code, highlighted. Complete on its own. The
 *           box already holds the taller of the two versions, so
 *           nothing below it moves while the change plays
 *  view     40% in view, the change plays one hunk at a time,
 *           0.46s apart (tighter when there are many):
 *    0ms    a soft band glides from the last hunk onto this one
 *           and the box scrolls it into view if it has to
 *  120ms    removed lines tint red and a strike draws across them,
 *           left to right, in 220ms
 *  340ms    they close while the new lines open under them, tinted
 *           green, on one spring (0.32s, no bounce), so the code
 *           below slides as one block. The "+N −M" counter ticks
 *           and the line numbers below roll to their new values,
 *           a wave 8ms per line, top to bottom
 *  review   each hunk that lands gets a small Undo / Keep pill at
 *           its right edge, and the header offers Undo all / Keep
 *           all. Keep fades the tint in 200ms. Undo plays the hunk
 *           backwards on a quicker spring (0.28s): the new lines
 *           close, the old ones reopen and the strike pulls back.
 *           Both work while later hunks are still playing
 *  settle   with `review` off, tints fade 1.4s after a hunk lands
 *  again    Replay fades the file back to `before` and plays it
 *           again; `play` drives the same thing from outside
 *  keys     every control is a button in reading order: header
 *           actions, then each hunk's Undo and Keep. Screen readers
 *           get the file's current text and hear "3 changes
 *           applied", "Change 2 undone"
 *  reduced  no band, strike, fold or roll: the finished diff appears
 *           at once, tinted, and Keep / Undo swap instantly
 * ───────────────────────────────────────────────────────── */

const DIFF = {
  view: 0.4, // share of the block in view before it plays
  inlineBelow: 544, // px; narrower boxes get review strips instead of floating pills
} as const satisfies Record<string, number>;

// The fold at a given `duration`, and undo kept quicker in the same proportion.
const foldOf = (duration: number) => ({ ...TIMING.fold, visualDuration: duration });
const undoOf = (duration: number) => ({
  ...TIMING.undo,
  visualDuration: TIMING.undo.visualDuration * (duration / TIMING.fold.visualDuration),
});

/* ── Public types ───────────────────────────────────────── */

/** What the visitor decided for one change. */
export type DiffApplyDecision = "pending" | "kept" | "undone";

export interface DiffApplyProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
  /** The file as it was. */
  before: string;
  /** The file after the edit. The component works out the changed lines itself. */
  after: string;
  /** Shown in the tab. Its extension picks the highlighting when `language` isn't set. */
  filename?: string;
  /** Highlighting: `ts`/`js`, `json`, `css` or `md`. Anything else is plain. Defaults to the filename's extension. */
  language?: string;
  /** After a change lands, wait for Keep or Undo. Off, the tint fades on its own and the file reads as finished. */
  review?: boolean;
  /** Leave unset to play once the block is 40% in view. Set it to drive the block yourself: `true` plays the change, `false` puts the file back to `before`. */
  play?: boolean;
  /** Show a Replay button once the change has played. */
  replay?: boolean;
  /** Wrap long lines instead of scrolling sideways. Use it for prose. */
  wrap?: boolean;
  /** Show the line-number gutter. */
  lineNumbers?: boolean;
  /** Cap the code box's height (px or any CSS length). The box scrolls inside and follows each change into view. */
  maxHeight?: number | string;
  /** Seconds between one change starting and the next. Files with many changes tighten it, so the whole run stays short. */
  stagger?: number;
  /** Seconds removed lines take to close and new lines to open. Undo plays back a little quicker. */
  duration?: number;
  /** Called once every change has landed. */
  onComplete?: () => void;
  /** Called after each Keep or Undo with the decision for every change, in order. */
  onReview?: (decisions: DiffApplyDecision[]) => void;
}

/* ── Diff ───────────────────────────────────────────────── */

type Op = { kind: "same" | "del" | "add"; text: string; at: number }; // at: line index in its own version

/** A line diff by longest common subsequence: plenty for files of a few hundred lines. */
function diffLines(a: string[], b: string[]): Op[] {
  const n = a.length;
  const m = b.length;
  const table: Uint32Array[] = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
  for (let i = n - 1; i >= 0; i--) {
    for (let j = m - 1; j >= 0; j--) {
      table[i][j] = a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
    }
  }
  const ops: Op[] = [];
  let i = 0;
  let j = 0;
  while (i < n && j < m) {
    if (a[i] === b[j]) {
      ops.push({ kind: "same", text: b[j], at: j });
      i++;
      j++;
    } else if (table[i + 1][j] >= table[i][j + 1]) {
      ops.push({ kind: "del", text: a[i], at: i++ });
    } else {
      ops.push({ kind: "add", text: b[j], at: j++ });
    }
  }
  while (i < n) ops.push({ kind: "del", text: a[i], at: i++ });
  while (j < m) ops.push({ kind: "add", text: b[j], at: j++ });
  return ops;
}

/* ── Highlighting ───────────────────────────────────────── */

type TokenType = "plain" | "keyword" | "string" | "number" | "comment" | "function" | "property" | "type" | "punctuation";
type Token = { type: TokenType; text: string };

// One map to retheme the highlighter, the same hues as Polyglot.
const TOKEN_CLASS: Record<TokenType, string> = {
  plain: "",
  keyword: "text-rose-600 dark:text-rose-400",
  string: "text-emerald-700 dark:text-emerald-300",
  number: "text-blue-600 dark:text-sky-300",
  comment: "text-muted-foreground italic",
  function: "text-violet-600 dark:text-violet-300",
  property: "text-amber-700 dark:text-amber-200",
  type: "text-cyan-700 dark:text-cyan-300",
  punctuation: "text-muted-foreground",
};

type Lang = "js" | "json" | "css" | "md" | "text";

const LANGS: Record<string, Lang> = {
  js: "js",
  jsx: "js",
  ts: "js",
  tsx: "js",
  mjs: "js",
  cjs: "js",
  mts: "js",
  javascript: "js",
  typescript: "js",
  json: "json",
  jsonc: "json",
  css: "css",
  md: "md",
  mdx: "md",
  markdown: "md",
};

const toLang = (language: string | undefined, filename: string | undefined): Lang => {
  const key = (language ?? filename?.split(".").pop() ?? "").toLowerCase();
  return LANGS[key] ?? "text";
};

const words = (list: string) => new Set(list.split(" "));
const JS_KEYWORDS = words(
  "as async await break case catch class const continue default delete do else enum export extends finally for from function if import in instanceof interface let new of return satisfies static switch throw try type typeof var void while yield",
);
const JS_CONSTANTS = words("true false null undefined this");
const JS_TYPES = words("string number boolean any unknown never void Promise Record");

type Push = (type: TokenType, text: string) => void;

const isSpace = (c: string | undefined) => c === " " || c === "\t" || c === "\n" || c === "\r";

function nextNonSpace(code: string, from: number) {
  let i = from;
  while (i < code.length && (code[i] === " " || code[i] === "\t")) i++;
  return i;
}

function readString(code: string, i: number, quote: string) {
  let j = i + 1;
  while (j < code.length) {
    if (code[j] === "\\") {
      j += 2;
      continue;
    }
    if (code[j] === quote) return j + 1;
    if (code[j] === "\n" && quote !== "`") return j;
    j++;
  }
  return code.length;
}

function lexJs(code: string, push: Push) {
  let i = 0;
  let last = "";
  const emit = (type: TokenType, text: string) => {
    push(type, text);
    const trimmed = text.trimEnd();
    if (trimmed) last = trimmed[trimmed.length - 1];
  };
  while (i < code.length) {
    const c = code[i];
    if (isSpace(c)) {
      let j = i;
      while (isSpace(code[j])) j++;
      push("plain", code.slice(i, j));
      i = j;
    } else if (code.startsWith("//", i)) {
      const end = code.indexOf("\n", i);
      const j = end < 0 ? code.length : end;
      emit("comment", code.slice(i, j));
      i = j;
    } else if (code.startsWith("/*", i)) {
      const end = code.indexOf("*/", i + 2);
      const j = end < 0 ? code.length : end + 2;
      emit("comment", code.slice(i, j));
      i = j;
    } else if (c === '"' || c === "'" || c === "`") {
      const j = readString(code, i, c);
      const after = nextNonSpace(code, j);
      emit(code[after] === ":" && c !== "`" ? "property" : "string", code.slice(i, j));
      i = j;
    } else if (/\d/.test(c)) {
      const m = /(?:0[xob][\da-f_]+|\d[\d_]*(?:\.\d+)?(?:e[+-]?\d+)?)n?/iy;
      m.lastIndex = i;
      const text = m.exec(code)?.[0] || c;
      emit("number", text);
      i += text.length;
    } else if (/[A-Za-z_$]/.test(c)) {
      let j = i;
      while (j < code.length && /[\w$]/.test(code[j])) j++;
      const word = code.slice(i, j);
      const after = nextNonSpace(code, j);
      let type: TokenType = "plain";
      if (JS_KEYWORDS.has(word) && last !== ".") type = "keyword";
      else if (JS_CONSTANTS.has(word)) type = "number";
      else if (code[j] === "(") type = "function";
      else if (code[after] === ":" && code[after + 1] !== ":") type = "property";
      else if (last === ".") type = "property";
      else if (/^[A-Z]/.test(word) || JS_TYPES.has(word)) type = "type";
      emit(type, word);
      i = j;
    } else {
      emit("punctuation", c);
      i++;
    }
  }
}

function lexJson(code: string, push: Push) {
  let i = 0;
  while (i < code.length) {
    const c = code[i];
    if (isSpace(c)) {
      let j = i;
      while (isSpace(code[j])) j++;
      push("plain", code.slice(i, j));
      i = j;
    } else if (c === '"') {
      const j = readString(code, i, '"');
      push(code[nextNonSpace(code, j)] === ":" ? "property" : "string", code.slice(i, j));
      i = j;
    } else if (/[-\d]/.test(c)) {
      const m = /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
      m.lastIndex = i;
      const text = m.exec(code)?.[0] || c;
      push(text === c && !/\d/.test(c) ? "punctuation" : "number", text);
      i += text.length;
    } else if (/[a-z]/i.test(c)) {
      let j = i;
      while (j < code.length && /[a-z]/i.test(code[j])) j++;
      push("number", code.slice(i, j));
      i = j;
    } else {
      push("punctuation", c);
      i++;
    }
  }
}

function lexCss(code: string, push: Push) {
  let i = 0;
  let depth = 0;
  let value = false; // after a property's colon, until ; or }
  while (i < code.length) {
    const c = code[i];
    if (isSpace(c)) {
      let j = i;
      while (isSpace(code[j])) j++;
      push("plain", code.slice(i, j));
      i = j;
    } else if (code.startsWith("/*", i)) {
      const end = code.indexOf("*/", i + 2);
      const j = end < 0 ? code.length : end + 2;
      push("comment", code.slice(i, j));
      i = j;
    } else if (c === '"' || c === "'") {
      const j = readString(code, i, c);
      push("string", code.slice(i, j));
      i = j;
    } else if (c === "{" || c === "}" || c === ";" || c === ":" || c === "," || c === "(" || c === ")") {
      if (c === "{") depth++;
      if (c === "}") depth = Math.max(0, depth - 1);
      if (c === ":" && depth > 0) value = true;
      if (c === ";" || c === "}" || c === "{") value = false;
      push("punctuation", c);
      i++;
    } else if (c === "@") {
      let j = i + 1;
      while (j < code.length && /[\w-]/.test(code[j])) j++;
      push("keyword", code.slice(i, j));
      i = j;
    } else if (value && (c === "#" || /[\d.-]/.test(c))) {
      const m = /#[\da-f]{3,8}|-?\d*\.?\d+(?:[a-z%]+)?/iy;
      m.lastIndex = i;
      const text = m.exec(code)?.[0] || c;
      push("number", text);
      i += text.length;
    } else {
      let j = i;
      while (j < code.length && !isSpace(code[j]) && !"{}:;,()\"'".includes(code[j])) j++;
      if (j === i) j++;
      const word = code.slice(i, j);
      if (value) push(code[j] === "(" ? "function" : "plain", word);
      else if (depth > 0 && code[nextNonSpace(code, j)] === ":") push("property", word);
      else push("function", word); // a selector
      i = j;
    }
  }
}

function lexMarkdownLine(line: string, push: Push) {
  const heading = /^(#{1,6})(\s.*)$/.exec(line);
  if (heading) {
    push("punctuation", heading[1]);
    push("function", heading[2]);
    return;
  }
  if (/^\s*>/.test(line)) {
    push("comment", line);
    return;
  }
  let rest = line;
  const marker = /^(\s*)([-*+]|\d+\.)(\s)/.exec(rest);
  if (marker) {
    push("plain", marker[1]);
    push("punctuation", marker[2]);
    push("plain", marker[3]);
    rest = rest.slice(marker[0].length);
  }
  const inline = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\[[^\]]+\]\([^)]+\))/g;
  let k = 0;
  for (const m of rest.matchAll(inline)) {
    push("plain", rest.slice(k, m.index));
    if (m[1]) push("string", m[1]);
    else if (m[2]) push("property", m[2]);
    else {
      const close = m[0].indexOf("](");
      push("plain", m[0].slice(0, close + 1));
      push("type", m[0].slice(close + 1));
    }
    k = (m.index ?? 0) + m[0].length;
  }
  push("plain", rest.slice(k));
}

/** Tokenizes a whole version and cuts it into lines, so comments and template strings that span lines still color right. */
function highlight(lines: string[], lang: Lang): Token[][] {
  if (lang === "text") return lines.map((text) => [{ type: "plain", text }]);
  if (lang === "md") {
    return lines.map((text) => {
      const out: Token[] = [];
      lexMarkdownLine(text, (type, t) => {
        if (!t) return;
        const prev = out[out.length - 1];
        if (prev && prev.type === type) prev.text += t;
        else out.push({ type, text: t });
      });
      return out;
    });
  }
  const code = lines.join("\n");
  const types: TokenType[] = [];
  const push: Push = (type, text) => {
    for (let k = 0; k < text.length; k++) types.push(type);
  };
  if (lang === "json") lexJson(code, push);
  else if (lang === "css") lexCss(code, push);
  else lexJs(code, push);
  const out: Token[][] = [];
  let offset = 0;
  for (const line of lines) {
    const row: Token[] = [];
    for (let k = 0; k < line.length; k++) {
      const type = types[offset + k] ?? "plain";
      const prev = row[row.length - 1];
      if (prev && prev.type === type) prev.text += line[k];
      else row.push({ type, text: line[k] });
    }
    out.push(row);
    offset += line.length + 1;
  }
  return out;
}

/* ── Model ──────────────────────────────────────────────── */

type Row = { key: string; kind: Op["kind"]; tokens: Token[]; hunk: number; index: number }; // index: its place in display order
type Hunk = { dels: Row[]; adds: Row[]; first: number }; // first: index of its first row
type Part = { kind: "same"; row: Row } | { kind: "hunk"; index: number };
type Model = { rows: Row[]; parts: Part[]; hunks: Hunk[]; before: string[]; after: string[] };

// Drops the first line when it's empty and trailing whitespace, so template literals read naturally.
const clean = (text: string) => text.replace(/\r\n?/g, "\n").replace(/^[ \t]*\n/, "").trimEnd();

function buildModel(before: string, after: string, lang: Lang): Model {
  const a = clean(before).split("\n");
  const b = clean(after).split("\n");
  const ta = highlight(a, lang);
  const tb = highlight(b, lang);
  const parts: Part[] = [];
  const hunks: Hunk[] = [];
  let open: Hunk | null = null;
  for (const op of diffLines(a, b)) {
    if (op.kind === "same") {
      open = null;
      parts.push({ kind: "same", row: { key: `s${op.at}`, kind: "same", tokens: tb[op.at], hunk: -1, index: 0 } });
      continue;
    }
    if (!open) {
      open = { dels: [], adds: [], first: 0 };
      hunks.push(open);
      parts.push({ kind: "hunk", index: hunks.length - 1 });
    }
    const del = op.kind === "del";
    (del ? open.dels : open.adds).push({
      key: `${del ? "d" : "a"}${op.at}`,
      kind: op.kind,
      tokens: del ? ta[op.at] : tb[op.at],
      hunk: hunks.length - 1,
      index: 0,
    });
  }
  // Display order: inside a hunk, the removed lines sit above the new ones.
  const rows: Row[] = [];
  for (const part of parts) {
    if (part.kind === "same") rows.push(part.row);
    else {
      const hunk = hunks[part.index];
      hunk.first = rows.length;
      rows.push(...hunk.dels, ...hunk.adds);
    }
  }
  rows.forEach((row, i) => (row.index = i));
  return { rows, parts, hunks, before: a, after: b };
}

/* ── Run state ──────────────────────────────────────────── */

type Status = "pending" | "striking" | "applied" | "kept" | "undone";

type Run = {
  token: number; // bumps on every play; 0 means it never played
  statuses: Status[];
  active: number | null; // the hunk under the band
  playing: boolean;
  wave: number; // first row of the hunk that changed last, where the line-number wave starts
  message: string;
};

const landed = (s: Status) => s === "applied" || s === "kept";
const decisionOf = (s: Status): DiffApplyDecision => (s === "kept" ? "kept" : s === "undone" ? "undone" : "pending");
const plural = (n: number, one: string) => `${n} ${one}${n === 1 ? "" : "s"}`;
const noopSubscribe = () => () => {};

/* ── DiffApply ──────────────────────────────────────────── */

export function DiffApply({
  before,
  after,
  filename,
  language,
  review = true,
  play,
  replay = true,
  wrap = false,
  lineNumbers = true,
  maxHeight,
  stagger: staggerProp = TIMING.stagger,
  duration = TIMING.fold.visualDuration,
  onComplete,
  onReview,
  className,
  ...props
}: DiffApplyProps) {
  const hydrated = React.useSyncExternalStore(noopSubscribe, () => true, () => false);
  const prefersReduced = useReducedMotion() ?? false;
  const reduced = prefersReduced && hydrated;

  const lang = toLang(language, filename);
  const model = React.useMemo(() => buildModel(before, after, lang), [before, after, lang]);
  const count = model.hunks.length;
  const idle = (): Status[] => Array.from({ length: count }, () => "pending");

  const [run, setRun] = React.useState<Run>(() => ({
    token: 0,
    statuses: idle(),
    active: null,
    playing: false,
    wave: 0,
    message: "",
  }));
  // New text starts over from `before`.
  const [modelSeen, setModelSeen] = React.useState(model);
  if (modelSeen !== model) {
    setModelSeen(model);
    setRun((r) => ({ ...r, statuses: idle(), active: null, playing: false }));
  }
  const statuses = run.statuses.length === count ? run.statuses : idle();

  const start = React.useCallback(() => {
    setRun((r) => ({
      token: r.token + 1,
      statuses: Array.from({ length: count }, () => "pending"),
      active: null,
      playing: count > 0,
      wave: 0,
      message: "",
    }));
  }, [count]);

  const reset = React.useCallback(() => {
    setRun((r) => ({
      token: r.token + 1,
      statuses: Array.from({ length: count }, () => "pending"),
      active: null,
      playing: false,
      wave: 0,
      message: "",
    }));
  }, [count]);

  // `play` from outside: adjust during render, the effect below does the timing.
  // Starts unset, so `play` already true on mount plays right away.
  const [playSeen, setPlaySeen] = React.useState<boolean | undefined>(undefined);
  if (play !== playSeen) {
    setPlaySeen(play);
    if (play === true) start();
    else if (play === false) reset();
  }

  const rootRef = React.useRef<HTMLDivElement>(null);
  const scrollerRef = React.useRef<HTMLDivElement>(null);
  const contentRef = React.useRef<HTMLDivElement>(null);
  const timers = React.useRef<number[]>([]);
  const scrollAnim = React.useRef<AnimationPlaybackControls | null>(null);
  const bandHost = React.useRef<HTMLElement | null>(null);
  // The hunk the band sat on last, so the next band can fly in from it.
  const swapBandHost = React.useCallback((el: HTMLElement) => {
    const prev = bandHost.current;
    bandHost.current = el;
    return prev;
  }, []);
  const callbacks = React.useRef({ onComplete, onReview });
  React.useEffect(() => {
    callbacks.current = { onComplete, onReview };
  });

  const clearTimers = React.useCallback(() => {
    timers.current.forEach((id) => window.clearTimeout(id));
    timers.current = [];
  }, []);

  // Plays once 40% of the block is in view, unless `play` drives it.
  const auto = play === undefined;
  React.useEffect(() => {
    const root = rootRef.current;
    if (!auto || !root) return;
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (!entry?.isIntersecting) return;
        observer.disconnect();
        setRun((r) =>
          r.token === 0
            ? { token: 1, statuses: Array.from({ length: count }, () => "pending"), active: null, playing: count > 0, wave: 0, message: "" }
            : r,
        );
      },
      { threshold: DIFF.view },
    );
    observer.observe(root);
    return () => observer.disconnect();
  }, [auto, count]);

  // Keeps the hunk being edited in view inside the box, never by scrolling the page.
  const reveal = React.useCallback(
    (index: number) => {
      const scroller = scrollerRef.current;
      const content = contentRef.current;
      const host = content?.querySelector<HTMLElement>(`[data-hunk="${index}"]`);
      if (!scroller || !host || scroller.scrollHeight <= scroller.clientHeight + 1) return;
      const hunk = model.hunks[index];
      const line = 24;
      const span = Math.max(hunk.dels.length, hunk.adds.length, 1) * line;
      const view = scroller.clientHeight;
      const top = host.offsetTop;
      if (top >= scroller.scrollTop + line && top + span <= scroller.scrollTop + view - line) return;
      const target = Math.max(0, Math.min(top - (view - span) / 2, scroller.scrollHeight - view));
      scrollAnim.current?.stop();
      scrollAnim.current = reduced
        ? null
        : animate(scroller.scrollTop, target, {
            ...TIMING.scroll,
            onUpdate: (v) => {
              scroller.scrollTop = v;
            },
          });
      if (reduced) scroller.scrollTop = target;
    },
    [model, reduced],
  );

  // A visitor scrolling the box takes it over from the follow.
  React.useEffect(() => {
    const scroller = scrollerRef.current;
    if (!scroller) return;
    const stop = () => scrollAnim.current?.stop();
    scroller.addEventListener("wheel", stop, { passive: true });
    scroller.addEventListener("touchstart", stop, { passive: true });
    return () => {
      scroller.removeEventListener("wheel", stop);
      scroller.removeEventListener("touchstart", stop);
    };
  }, []);

  // The timeline for one run. Every step is a guarded state change, so Undo
  // or Keep mid-run simply wins.
  React.useEffect(() => {
    if (run.token === 0 || !run.playing) return;
    const at = (seconds: number, fn: () => void) => {
      timers.current.push(window.setTimeout(fn, seconds * 1000));
    };
    const setStatus = (index: number, from: Status[], to: Status) =>
      setRun((r) => {
        if (!from.includes(r.statuses[index])) return r;
        const next = r.statuses.slice();
        next[index] = to;
        return { ...r, statuses: next, wave: model.hunks[index].first };
      });
    const finish = () => {
      setRun((r) => {
        const n = r.statuses.filter(landed).length;
        return { ...r, active: null, playing: false, message: n ? `${plural(n, "change")} applied` : r.message };
      });
      callbacks.current.onComplete?.();
    };

    if (reduced) {
      at(0, () => {
        setRun((r) => {
          const next = r.statuses.map((s): Status => (s === "pending" || s === "striking" ? (review ? "applied" : "kept") : s));
          return { ...r, statuses: next, wave: 0 };
        });
        finish();
      });
      return clearTimers;
    }

    // `stagger` scales the floor and the spread with it, so many changes still tighten the same way.
    const pace = Math.max(0, staggerProp) / TIMING.stagger;
    const stagger = Math.min(
      TIMING.stagger * pace,
      Math.max(TIMING.minStagger * pace, (TIMING.spread * pace) / Math.max(1, count)),
    );
    let end = 0;
    model.hunks.forEach((hunk, i) => {
      const t = TIMING.start + i * stagger;
      at(t, () => {
        setRun((r) => (r.statuses[i] === "pending" ? { ...r, active: i } : r));
        reveal(i);
      });
      const strike = hunk.dels.length ? TIMING.strike.duration : 0;
      if (hunk.dels.length) at(t + TIMING.lead, () => setStatus(i, ["pending"], "striking"));
      const land = t + TIMING.lead + strike;
      at(land, () => setStatus(i, ["pending", "striking"], "applied"));
      if (!review) at(land + TIMING.settle, () => setStatus(i, ["applied"], "kept"));
      end = land + duration;
    });
    at(end, finish);
    return clearTimers;
  }, [run.token, run.playing, model, count, review, reduced, reveal, clearTimers, staggerProp, duration]);

  React.useEffect(() => () => scrollAnim.current?.stop(), []);

  /* decisions */
  const decide = (indices: number[], to: "kept" | "undone", message: string) => {
    const pick = (s: Status, k: number) =>
      indices.includes(k) && (to === "kept" ? s !== "kept" && s !== "undone" : s !== "undone" && s !== "kept") ? to : s;
    const next = statuses.map(pick);
    const stillPlaying = run.playing && next.some((s) => s === "pending" || s === "striking");
    if (!stillPlaying) clearTimers();
    setRun((r) => {
      const statusesNext = r.statuses.map(pick);
      return {
        ...r,
        statuses: statusesNext,
        playing: stillPlaying,
        active: stillPlaying ? r.active : null,
        wave: indices.length ? model.hunks[indices[0]].first : r.wave,
        message,
      };
    });
    callbacks.current.onReview?.(next.map(decisionOf));
    if (run.playing && !stillPlaying && to === "kept") callbacks.current.onComplete?.();
    // The button that was pressed goes away: hand focus to the next change
    // still waiting, or back to the code.
    const root = rootRef.current;
    if (root && root.contains(document.activeElement) && document.activeElement !== root) {
      const after = indices[indices.length - 1] ?? -1;
      const order = [...next.keys()].filter((k) => next[k] === "applied");
      const target = order.find((k) => k > after) ?? order[0];
      requestAnimationFrame(() => {
        const button =
          target === undefined ? null : root.querySelector<HTMLElement>(`[data-keep="${target}"]`);
        (button ?? scrollerRef.current)?.focus({ preventScroll: true });
      });
    }
  };

  const waiting = statuses.map((s, i) => (s === "applied" ? i : -1)).filter((i) => i >= 0);
  const all = model.hunks.map((_, i) => i);
  const adds = model.hunks.reduce((sum, h, i) => sum + (landed(statuses[i]) ? h.adds.length : 0), 0);
  const dels = model.hunks.reduce((sum, h, i) => sum + (landed(statuses[i]) ? h.dels.length : 0), 0);
  const touched = statuses.some((s) => s !== "pending");
  const finished = run.token > 0 && !run.playing;

  /* the box holds the taller version, so the card never resizes mid-run */
  const [reserve, setReserve] = React.useState(0);
  // Pills float over the code's right edge in a wide box. Prose fills the line
  // and a narrow box has no spare edge, so there they open as a strip under the
  // change instead of covering words.
  const [narrow, setNarrow] = React.useState(false);
  const inline = wrap || narrow;
  // Before it can measure (the server render), every line is one 24px row.
  const estimate = Math.max(model.before.length, model.after.length) * 24;
  React.useLayoutEffect(() => {
    const scroller = scrollerRef.current;
    if (!scroller) return;
    const measure = () => {
      const content = contentRef.current;
      if (!content) return;
      let a = 0;
      let b = 0;
      content.querySelectorAll<HTMLElement>("[data-ver]").forEach((el) => {
        const h = el.offsetHeight;
        const ver = el.dataset.ver;
        if (ver !== "after") a += h;
        if (ver !== "before") b += h;
      });
      setReserve(Math.ceil(Math.max(a, b)));
      setNarrow(scroller.clientWidth < DIFF.inlineBelow);
      scroller.style.setProperty("--diff-view", `${scroller.clientWidth}px`);
    };
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(scroller);
    document.fonts?.ready.then(measure).catch(() => {});
    return () => observer.disconnect();
  }, [model, wrap, lineNumbers, inline, review]);

  /* line numbers: count the lines that are in the file right now */
  const digits = String(Math.max(model.before.length, model.after.length)).length;
  const numbers: (number | null)[] = [];
  {
    let n = 0;
    for (const row of model.rows) {
      const s = row.hunk >= 0 ? statuses[row.hunk] : "pending";
      const live = row.kind === "same" || (row.kind === "del" ? !landed(s) : landed(s));
      numbers.push(live ? ++n : null);
    }
  }

  // What a screen reader gets: the file as it reads right now.
  const current = model.parts
    .flatMap((part) => {
      if (part.kind === "same") return [part.row];
      const hunk = model.hunks[part.index];
      return landed(statuses[part.index]) ? hunk.adds : hunk.dels;
    })
    .map((row) => row.tokens.map((t) => t.text).join(""))
    .join("\n");

  const rowProps = { digits, lineNumbers, wrap, reduced, wave: run.wave };

  return (
    <div
      ref={rootRef}
      className={cn(
        "@container/diff relative w-full overflow-hidden rounded-xl border border-border bg-background text-foreground shadow-[0_1px_2px_rgb(0_0_0/0.04),0_12px_32px_-16px_rgb(0_0_0/0.14)]",
        className,
      )}
      {...props}
    >
      {/* header */}
      <div className="flex h-11 items-center gap-3 border-b border-border bg-muted/40 pr-1.5 pl-3.5">
        <div className="flex min-w-0 items-center gap-2">
          <svg aria-hidden viewBox="0 0 16 16" className="size-3.5 shrink-0 text-muted-foreground" fill="none">
            <path
              d="M4 1.75h5.25L12.5 5v8.25a1 1 0 0 1-1 1h-7.5a1 1 0 0 1-1-1V2.75a1 1 0 0 1 1-1Z"
              stroke="currentColor"
              strokeWidth="1.25"
              strokeLinejoin="round"
            />
            <path d="M9 1.75V5.25h3.5" stroke="currentColor" strokeWidth="1.25" strokeLinejoin="round" />
          </svg>
          <span className="truncate text-[13px] font-medium">{filename ?? "Untitled"}</span>
        </div>
        <motion.div
          aria-hidden
          initial={false}
          animate={{ opacity: touched ? 1 : 0 }}
          transition={TIMING.tint}
          className="flex shrink-0 items-center gap-1.5 font-mono text-[12px] tabular-nums"
        >
          <span className="flex text-emerald-600 dark:text-emerald-400">
            +<Roll value={adds} reduced={reduced} />
          </span>
          <span className="flex text-rose-600 dark:text-rose-400">
            −<Roll value={dels} reduced={reduced} />
          </span>
        </motion.div>
        <div className="ml-auto flex shrink-0 items-center gap-1">
          <AnimatePresence initial={false}>
            {review && waiting.length > 0 ? (
              <motion.div
                key="all"
                initial={reduced ? { opacity: 0 } : { opacity: 0, x: 6 }}
                animate={{ opacity: 1, x: 0 }}
                exit={{ opacity: 0, transition: TIMING.actionsOut }}
                transition={TIMING.pill}
                className="flex items-center gap-1"
              >
                <HeaderButton
                  label="Undo all"
                  onClick={() => decide(all, "undone", "All changes undone")}
                  icon={<UndoIcon />}
                />
                <HeaderButton
                  label="Keep all"
                  strong
                  onClick={() => decide(all, "kept", "All changes kept")}
                  icon={<CheckIcon />}
                />
              </motion.div>
            ) : null}
          </AnimatePresence>
          {replay && finished && count > 0 ? (
            <button
              type="button"
              onClick={start}
              aria-label="Replay the change"
              title="Replay"
              className={`inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none transition-colors ${TIMING.press} hover:bg-foreground/5 hover:text-foreground focus-visible:ring-2 focus-visible:ring-foreground/30`}
            >
              <svg aria-hidden viewBox="0 0 16 16" className="size-3.5" fill="none">
                <path
                  d="M2.75 8a5.25 5.25 0 1 0 1.6-3.77M2.75 2.5v2.75H5.5"
                  stroke="currentColor"
                  strokeWidth="1.4"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
              </svg>
            </button>
          ) : null}
        </div>
      </div>

      {/* code */}
      <div
        ref={scrollerRef}
        role="region"
        aria-label={filename ? `Changes to ${filename}` : "Changes"}
        tabIndex={0}
        style={{ maxHeight: typeof maxHeight === "number" ? `${maxHeight}px` : maxHeight }}
        className={cn(
          "relative outline-none [scrollbar-width:thin] focus-visible:ring-2 focus-visible:ring-foreground/20 focus-visible:ring-inset",
          maxHeight != null ? "overflow-auto" : wrap ? "overflow-hidden" : "overflow-x-auto overflow-y-hidden",
        )}
      >
        <pre className="sr-only">{current}</pre>
        <motion.div
          key={run.token}
          ref={contentRef}
          initial={{ opacity: run.token > 1 && !reduced ? 0 : 1 }}
          animate={{ opacity: 1 }}
          transition={TIMING.replay}
          style={{ minHeight: (reserve || estimate) + 24 }}
          className={cn(
            "relative py-3 font-mono text-[13px] leading-6 [tab-size:2]",
            wrap ? "w-full" : "w-max min-w-full",
          )}
        >
          {model.parts.map((part) => {
            if (part.kind === "same") {
              const index = part.row.index;
              return <SameRow key={part.row.key} row={part.row} number={numbers[index]} index={index} {...rowProps} />;
            }
            const i = part.index;
            const hunk = model.hunks[i];
            const status = statuses[i];
            return (
              <div key={`h${i}`} data-hunk={i} className="relative">
                <AnimatePresence>
                  {run.active === i && !reduced ? <Band key="band" swapHost={swapBandHost} /> : null}
                </AnimatePresence>
                {hunk.dels.length > 0 ? (
                  <motion.span
                    aria-hidden
                    initial={false}
                    animate={{ opacity: status === "applied" ? 1 : 0 }}
                    transition={TIMING.tint}
                    className="pointer-events-none absolute inset-x-0 top-0 z-[1] h-px bg-rose-500/60 dark:bg-rose-400/60"
                  />
                ) : null}
                <AnimatePresence>
                  {review && !inline && status === "applied" ? (
                    <ReviewPill
                      key="pill"
                      index={i}
                      adds={hunk.adds.length}
                      dels={hunk.dels.length}
                      onSeam={hunk.adds.length === 0}
                      reduced={reduced}
                      onUndo={() => decide([i], "undone", `Change ${i + 1} undone`)}
                      onKeep={() => decide([i], "kept", `Change ${i + 1} kept`)}
                    />
                  ) : null}
                </AnimatePresence>
                {hunk.dels.map((row, k) => (
                  <ChangedRow
                    key={row.key}
                    row={row}
                    index={hunk.first + k}
                    number={numbers[hunk.first + k]}
                    open={!landed(status)}
                    struck={status === "striking" || status === "applied"}
                    tinted={status === "striking" || status === "applied"}
                    undoing={status === "undone"}
                    duration={duration}
                    {...rowProps}
                  />
                ))}
                {hunk.adds.map((row, k) => {
                  const index = hunk.first + hunk.dels.length + k;
                  return (
                    <ChangedRow
                      key={row.key}
                      row={row}
                      index={index}
                      number={numbers[index]}
                      open={landed(status)}
                      struck={false}
                      tinted={status === "applied"}
                      undoing={status === "undone"}
                      duration={duration}
                      {...rowProps}
                    />
                  );
                })}
                {review && inline ? (
                  <ReviewStrip
                    index={i}
                    adds={hunk.adds.length}
                    dels={hunk.dels.length}
                    open={status === "applied"}
                    reduced={reduced}
                    duration={duration}
                    onUndo={() => decide([i], "undone", `Change ${i + 1} undone`)}
                    onKeep={() => decide([i], "kept", `Change ${i + 1} kept`)}
                  />
                ) : null}
              </div>
            );
          })}
        </motion.div>
      </div>

      <div role="status" aria-live="polite" className="sr-only">
        {run.message}
      </div>
    </div>
  );
}

/* ── Rows ───────────────────────────────────────────────── */

type RowShared = {
  row: Row;
  index: number;
  number: number | null;
  digits: number;
  lineNumbers: boolean;
  wrap: boolean;
  reduced: boolean;
  wave: number;
};

const rollDelay = (index: number, wave: number) => Math.min(TIMING.rollSpread, Math.max(0, index - wave) * TIMING.rollStep);

function Code({ tokens }: { tokens: Token[] }) {
  return (
    <>
      {tokens.length === 0 ? " " : null}
      {tokens.map((token, i) => (
        <span key={i} className={TOKEN_CLASS[token.type]}>
          {token.text}
        </span>
      ))}
    </>
  );
}

function Gutter({
  number,
  digits,
  index,
  wave,
  reduced,
  sign,
  tone,
}: {
  number: number | null;
  digits: number;
  index: number;
  wave: number;
  reduced: boolean;
  sign: "+" | "−" | null;
  tone: React.ReactNode;
}) {
  return (
    <span
      aria-hidden
      style={{ width: `calc(${digits}ch + 2rem)` }}
      className="sticky left-0 z-[2] flex shrink-0 justify-end bg-background text-muted-foreground/45 tabular-nums select-none"
    >
      {tone}
      <span className="relative">
        <Roll value={number} delay={rollDelay(index, wave)} reduced={reduced} />
      </span>
      <span
        className={cn(
          "relative w-5 text-center",
          sign === "+" ? "text-emerald-600 dark:text-emerald-400" : "text-rose-600 dark:text-rose-400",
        )}
      >
        {sign}
      </span>
    </span>
  );
}

const SameRow = React.memo(function SameRow({ row, index, number, digits, lineNumbers, wrap, reduced, wave }: RowShared) {
  return (
    <div data-ver="both" aria-hidden className="flex">
      {lineNumbers ? (
        <Gutter number={number} digits={digits} index={index} wave={wave} reduced={reduced} sign={null} tone={null} />
      ) : (
        <span className="w-4 shrink-0" />
      )}
      <span className={cn("min-w-0 flex-1 pr-5", lineNumbers && "pl-1", wrap ? "break-words whitespace-pre-wrap" : "whitespace-pre")}>
        <Code tokens={row.tokens} />
      </span>
    </div>
  );
});

const ChangedRow = React.memo(function ChangedRow({
  row,
  index,
  number,
  digits,
  lineNumbers,
  wrap,
  reduced,
  wave,
  open,
  struck,
  tinted,
  undoing,
  duration,
}: RowShared & { open: boolean; struck: boolean; tinted: boolean; undoing: boolean; duration: number }) {
  const del = row.kind === "del";
  const fold = reduced ? { duration: 0 } : undoing ? undoOf(duration) : foldOf(duration);
  const tint = reduced ? { duration: 0 } : TIMING.tint;
  const rowTint = del ? "bg-rose-500/[0.09] dark:bg-rose-400/[0.12]" : "bg-emerald-500/[0.09] dark:bg-emerald-400/[0.11]";
  const gutterTint = del ? "bg-rose-500/[0.16] dark:bg-rose-400/[0.2]" : "bg-emerald-500/[0.16] dark:bg-emerald-400/[0.18]";
  return (
    <motion.div
      initial={false}
      animate={{ height: open ? "auto" : 0, opacity: open ? 1 : 0 }}
      transition={{
        height: fold,
        opacity: reduced ? { duration: 0 } : open ? TIMING.rowIn : TIMING.rowOut,
      }}
      className="relative overflow-clip"
    >
      <motion.span
        aria-hidden
        initial={false}
        animate={{ opacity: tinted ? 1 : 0 }}
        transition={tint}
        className={cn("pointer-events-none absolute inset-0", rowTint)}
      />
      <div data-ver={del ? "before" : "after"} aria-hidden className="relative flex">
        {lineNumbers ? (
          <Gutter
            number={number}
            digits={digits}
            index={index}
            wave={wave}
            reduced={reduced}
            sign={tinted ? (del ? "−" : "+") : null}
            tone={
              <motion.span
                initial={false}
                animate={{ opacity: tinted ? 1 : 0 }}
                transition={tint}
                className={cn("pointer-events-none absolute inset-0", gutterTint)}
              />
            }
          />
        ) : (
          <span className="w-4 shrink-0" />
        )}
        <span className={cn("relative min-w-0 flex-1 pr-5", lineNumbers && "pl-1", wrap ? "break-words whitespace-pre-wrap" : "whitespace-pre")}>
          <span className={cn(`transition-opacity ${TIMING.dim}`, struck && "opacity-60")}>
            <Code tokens={row.tokens} />
          </span>
          {del ? (
            // The strike: a transparent copy of the line with only its line-through
            // showing, revealed left to right, so wrapped lines strike too.
            <motion.span
              aria-hidden
              initial={false}
              animate={{ clipPath: struck ? "inset(0% 0% 0% 0%)" : "inset(0% 100% 0% 0%)" }}
              transition={reduced ? { duration: 0 } : struck ? TIMING.strike : TIMING.unstrike}
              className={cn(
                "pointer-events-none absolute inset-y-0 left-0 pr-5 text-transparent line-through decoration-rose-500 decoration-[1.5px] dark:decoration-rose-400",
                lineNumbers && "pl-1",
                wrap ? "right-0 whitespace-pre-wrap break-words" : "whitespace-pre",
              )}
            >
              {row.tokens.map((t) => t.text).join("") || " "}
            </motion.span>
          ) : null}
        </span>
      </div>
    </motion.div>
  );
});

/* ── Band ───────────────────────────────────────────────── */

// The soft band behind the hunk being edited. It mounts inside its hunk, so it
// follows the hunk's height as lines close and open, and flies in from the
// last hunk it sat on (a flat rectangle, so scaling it can't distort anything).
function Band({ swapHost }: { swapHost: (el: HTMLElement) => HTMLElement | null }) {
  const ref = React.useRef<HTMLSpanElement>(null);
  React.useLayoutEffect(() => {
    const el = ref.current;
    const mine = el?.parentElement;
    if (!el || !mine) return;
    const prev = swapHost(mine);
    if (!prev || prev === mine || !prev.isConnected) {
      animate(el, { opacity: [0, 1] }, TIMING.bandIn);
      return;
    }
    const a = prev.getBoundingClientRect();
    const b = mine.getBoundingClientRect();
    const fromH = Math.max(a.height, 2);
    const toH = Math.max(b.height, 2);
    animate(
      el,
      { transform: [`translateY(${a.top - b.top}px) scaleY(${fromH / toH})`, "translateY(0px) scaleY(1)"] },
      TIMING.band,
    );
  }, [swapHost]);
  return (
    <motion.span
      ref={ref}
      aria-hidden
      exit={{ opacity: 0, transition: TIMING.bandOut }}
      className="pointer-events-none absolute inset-x-0 top-0 bottom-0 z-0 min-h-0.5 origin-top bg-foreground/[0.045] dark:bg-foreground/[0.06]"
    >
      <span className="absolute inset-y-0 left-0 w-0.5 bg-foreground/35" />
    </motion.span>
  );
}

/* ── Review pill ────────────────────────────────────────── */

// Undo / Keep for one hunk. A zero-height strip pinned to the box's visible
// width keeps it at the right edge however far the code scrolls sideways.
function ReviewPill({
  index,
  adds,
  dels,
  onSeam,
  reduced,
  onUndo,
  onKeep,
}: {
  index: number;
  adds: number;
  dels: number;
  onSeam: boolean;
  reduced: boolean;
  onUndo: () => void;
  onKeep: () => void;
}) {
  return (
    <div className="sticky left-0 z-[3] h-0" style={{ width: "var(--diff-view, 100%)" }}>
      <motion.div
        initial={reduced ? { opacity: 0 } : { opacity: 0, y: -3, scale: 0.96 }}
        animate={{ opacity: 1, y: 0, scale: 1 }}
        exit={{ opacity: 0, scale: 0.97, transition: TIMING.pillOut }}
        transition={reduced ? TIMING.pillReduced : TIMING.pill}
        className={cn(
          "absolute right-2 flex origin-right items-center gap-0.5 rounded-md border border-border bg-background/95 p-0.5 font-sans shadow-[0_4px_12px_-6px_rgb(0_0_0/0.25)] backdrop-blur-sm",
          onSeam ? "top-0 -translate-y-1/2" : "top-0",
        )}
      >
        <span aria-hidden className="hidden px-1.5 font-mono text-[11px] tabular-nums @[26rem]/diff:inline">
          {dels ? <span className="text-rose-600 dark:text-rose-400">−{dels}</span> : null}
          {dels && adds ? " " : null}
          {adds ? <span className="text-emerald-600 dark:text-emerald-400">+{adds}</span> : null}
        </span>
        <button
          type="button"
          onClick={onUndo}
          aria-label={`Undo change ${index + 1}`}
          className={`h-5 cursor-pointer rounded-[5px] px-2 text-[11px] leading-5 font-medium text-muted-foreground outline-none transition-colors ${TIMING.press} hover:bg-foreground/5 hover:text-foreground focus-visible:ring-2 focus-visible:ring-foreground/30`}
        >
          Undo
        </button>
        <button
          type="button"
          onClick={onKeep}
          aria-label={`Keep change ${index + 1}`}
          data-keep={index}
          className={`h-5 cursor-pointer rounded-[5px] bg-foreground px-2 text-[11px] leading-5 font-medium text-background outline-none transition-opacity ${TIMING.press} hover:opacity-85 focus-visible:ring-2 focus-visible:ring-foreground/30 focus-visible:ring-offset-1 focus-visible:ring-offset-background`}
        >
          Keep
        </button>
      </motion.div>
    </div>
  );
}

// The same choice as a strip that opens under the change, for prose and
// narrow boxes. It stays mounted, closed and inert, so the box can reserve
// its height before the change plays.
function ReviewStrip({
  index,
  adds,
  dels,
  open,
  reduced,
  duration,
  onUndo,
  onKeep,
}: {
  index: number;
  adds: number;
  dels: number;
  open: boolean;
  reduced: boolean;
  duration: number;
  onUndo: () => void;
  onKeep: () => void;
}) {
  return (
    <motion.div
      initial={false}
      animate={{ height: open ? "auto" : 0, opacity: open ? 1 : 0 }}
      transition={
        reduced
          ? { duration: 0 }
          : { height: open ? foldOf(duration) : undoOf(duration), opacity: { duration: open ? TIMING.stripFade.open : TIMING.stripFade.close, ease: TIMING.stripFade.ease } }
      }
      inert={!open}
      className="relative overflow-clip"
    >
      <div
        data-ver="after"
        className="border-b border-emerald-500/15 bg-emerald-500/[0.045] dark:border-emerald-400/15 dark:bg-emerald-400/[0.06]"
      >
        {/* Pinned to the box's visible width, so the buttons never scroll away with long lines. */}
        <div
          style={{ width: "var(--diff-view, 100%)" }}
          className="sticky left-0 flex items-center gap-1 py-1 pr-2 pl-3 font-sans"
        >
        <span aria-hidden className="mr-auto font-mono text-[11px] text-muted-foreground tabular-nums">
          {dels ? <span className="text-rose-600 dark:text-rose-400">−{dels}</span> : null}
          {dels && adds ? " " : null}
          {adds ? <span className="text-emerald-600 dark:text-emerald-400">+{adds}</span> : null}
        </span>
        <button
          type="button"
          onClick={onUndo}
          aria-label={`Undo change ${index + 1}`}
          className={`h-7 cursor-pointer rounded-md px-2.5 text-[12px] font-medium text-muted-foreground outline-none transition-colors ${TIMING.press} hover:bg-foreground/5 hover:text-foreground focus-visible:ring-2 focus-visible:ring-foreground/30`}
        >
          Undo
        </button>
        <button
          type="button"
          onClick={onKeep}
          aria-label={`Keep change ${index + 1}`}
          data-keep={index}
          className={`h-7 cursor-pointer rounded-md bg-foreground px-2.5 text-[12px] font-medium text-background outline-none transition-opacity ${TIMING.press} hover:opacity-85 focus-visible:ring-2 focus-visible:ring-foreground/30 focus-visible:ring-offset-1 focus-visible:ring-offset-background`}
        >
          Keep
        </button>
        </div>
      </div>
    </motion.div>
  );
}

/* ── Header bits ────────────────────────────────────────── */

function HeaderButton({ label, onClick, icon, strong }: { label: string; onClick: () => void; icon: React.ReactNode; strong?: boolean }) {
  return (
    <button
      type="button"
      onClick={onClick}
      aria-label={label}
      className={cn(
        `inline-flex h-7 shrink-0 cursor-pointer items-center gap-1.5 rounded-md px-2 text-[12px] font-medium outline-none transition-[background-color,color,opacity] ${TIMING.press} focus-visible:ring-2 focus-visible:ring-foreground/30`,
        strong ? "bg-foreground text-background hover:opacity-85" : "text-muted-foreground hover:bg-foreground/5 hover:text-foreground",
      )}
    >
      <span className="size-3.5">{icon}</span>
      <span className="hidden @[30rem]/diff:inline">{label}</span>
    </button>
  );
}

function UndoIcon() {
  return (
    <svg aria-hidden viewBox="0 0 16 16" className="size-3.5" fill="none">
      <path d="M5.5 4 2.5 7l3 3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
      <path d="M3 7h6.25a4 4 0 0 1 0 8H7" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
    </svg>
  );
}

function CheckIcon() {
  return (
    <svg aria-hidden viewBox="0 0 16 16" className="size-3.5" fill="none">
      <path d="m3.25 8.5 3 3 6.5-7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

/* ── Roll ───────────────────────────────────────────────── */

// A number that rolls to its new value: up when it grows, down when it
// shrinks. Holds its last value while it has none (a line that's closing).
function Roll({ value, delay = 0, reduced }: { value: number | null; delay?: number; reduced: boolean }) {
  const [shown, setShown] = React.useState({ n: value, dir: 1 });
  if (value != null && value !== shown.n) setShown({ n: value, dir: value > (shown.n ?? 0) ? 1 : -1 });
  const transition = reduced ? { duration: 0 } : { ...TIMING.roll, delay };
  return (
    // A block-level grid: an inline one sits on the baseline and adds a few
    // pixels under every line while a number rolls.
    <span className="relative grid overflow-hidden">
      <AnimatePresence initial={false} custom={shown.dir}>
        <motion.span
          key={shown.n ?? "none"}
          custom={shown.dir}
          variants={{
            enter: (dir: number) => ({ y: dir > 0 ? "75%" : "-75%", opacity: 0 }),
            center: { y: "0%", opacity: 1 },
            exit: (dir: number) => ({ y: dir > 0 ? "-75%" : "75%", opacity: 0 }),
          }}
          initial="enter"
          animate="center"
          exit="exit"
          transition={transition}
          className="col-start-1 row-start-1"
        >
          {shown.n ?? "\u00a0"}
        </motion.span>
      </AnimatePresence>
    </span>
  );
}
```
