# Merge graph card

> An agent’s pull request card that draws its commit graph as it works, runs into conflicts, resolves them and merges.

A chat turn answering “Create PR”. The card is a commit graph of the branch: lanes of dots joined by curves, a row per commit with its message, a tinted branch pill, its short hash and who made it when. Rows decode into place as the agent commits, pushes, opens the pull request and fetches main, where a colleague’s new commits conflict. Resolve them and the base merges in conflict by conflict, the tests run and the card turns green; merge it and the branch turns purple.

A React component by Bruno Delorence: https://brunodelorence.com/components/merge-graph-card. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: AI.

## Anatomy

1. **Request**: The person’s message, right-aligned in a grey bubble: “Create PR”, or what they typed.
2. **Disclosure**: Thinking, with a shimmer and a seconds count, while the agent works; then Worked for Ns. It opens the log of what it did.
3. **Card**: The pull request: its title and number, an Open or Merged pill, the commit graph, the checks once green, and a footer.
4. **Commit row**: One commit: its band of the lanes, the message decoding into place, a branch pill or a conflict count, and its hash with who and when.
5. **Footer**: What is happening or left to do, and the one button that settles it: Resolve conflicts, Merge, or Open PR once merged.
6. **Answer**: The agent’s words, streamed one at a time, with the number in bold and file and branch names as code.
7. **Status chip**: Over the composer: Create PR before the turn, then the number and where it stands. The diff stat beside it lists the changed files.
8. **Composer**: A message line and its toolbar: attach, the repository, local or cloud, effort, dictate and send. Send asks for a new pull request.

## When to use

- An agent that opens a pull request for the person and has to show where it stands: open, conflicting, green, merged.
- Branch work where the shape of the history matters: what the branch adds, what moved on the base, where they meet.
- A turn that needs the person to settle one thing (resolve the conflicts, then merge) with a single button on the card.
- Chat or side-panel agents in a code tool, where the card sits in the thread above the composer.

## When not to use

- A list of many pull requests to triage. Use a table with status columns, one row each.
- A full review: diffs line by line and comments. Link to the code host or a diff viewer instead.
- Histories with more than three lanes or dozens of commits: the row-by-row graph is drawn for a short branch.
- Telling the person a merge already happened somewhere else. A one-line message with a link says it.

## Installation

Install it with the shadcn CLI, into components/merge-graph-card of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/merge-graph-card.json
```

Or add the registry to components.json once, as a namespace that coding agents and the shadcn MCP server search by name:

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

```sh
npx shadcn@latest add @brunodelorence/merge-graph-card
```

Or copy all the source files below into one folder of your project. Either way it needs React 19 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

## Usage

```tsx
import { MergeGraphCard } from './merge-graph-card';

export function ReviewTurn() {
  return (
    <MergeGraphCard
      title="Refresh sessions in the auth middleware"
      number={128}
      branch="feat/session-refresh"
      defaultStage="conflicts"
      autoplay={false}
      onMerge={() => console.log('merged')}
    />
  );
}
```

## States

- **Before the request**: Only the Create PR chip and the composer: tapping either starts the turn.
- **Conflicts**: The base moved on: two of its commits carry amber counts, the footer offers Resolve conflicts.
- **Ready to merge**: The base is merged in, the conflict marks are gone and four checks line up green over Merge.
- **Merged**: The top node and the pill turn purple, the diff stat leaves, and the button becomes Open PR.
- **Dark**: Every colour is a light-dark() token: `colorScheme="dark"`, or the page’s scheme without it.
- **Narrow**: Under 500 px of room the branch pills and hashes step aside and the composer keeps its icons; under 420 px the folder, dictate and diff blocks go too.

## How it works

- The whole turn is one model, and the agent’s work is a script of small patches to it, each after a pause: commit, push, open, fetch, check. Resolve and Merge play two more scripts. The rows and edges of the graph are worked out from the model, so any stage draws the same whether it is reached step by step or at once (defaultStage).
- The graph is drawn row by row: every edge is one path in the coordinates of the whole list, and each row has an SVG whose viewBox is its own 30 px band of it. A row that arrives later opens up between two others with the lanes running through it; nothing is laid over the list.
- Edges change lanes over at most two rows, next to the newer commit (a merge’s second parent) or the older one (where a branch leaves the base), with vertical tangents so they land on the dots. The link from the pending merge to the branch is dashed in the colour of its state, and draws itself in solid as it merges.
- New rows decode: the line grows from random letters to its length while the real words take over from the left, written straight to the text from a frame loop. Work in progress shimmers (a moving gradient clipped to the words), the conflict node pulses, and the answer streams in word by word with a short blur.
- Once merged, the branch’s dots, lines and pill turn purple row after row, with a tint sweeping down its commits; the checks fold away (a grid row from 1fr to 0fr).
- The script’s clock only runs while the card is on screen and the tab is visible. While untouched, a demo pointer plays the reader’s part, hovers the conflicts and the checks, resolves, merges, and starts over.
- Every colour is a light-dark() token on the root, so the card follows the page’s color-scheme, or the colorScheme prop.

## API reference

- **title** (`string`, default `'Refresh sessions in the auth middleware'`): The pull request’s title, on the card.
- **number** (`number`, default `128`): Its number.
- **branch** (`string`, default `'feat/session-refresh'`): The branch the agent works on.
- **base** (`string`, default `'main'`): The branch it merges into.
- **sideBranch** (`string`, default `'feat/checkout-analytics'`): A colleague’s branch off the base, drawn in its own lane.
- **repository** (`string`, default `'harbor-web'`): The folder chip in the composer.
- **defaultStage** (`'idle' | 'conflicts' | 'ready' | 'merged'`, default `'idle'`): Where the turn starts, already drawn: before Create PR, at the conflicts, green, or merged.
- **openLabel** (`string`, default `'Open PR'`): The button of a merged card.
- **prHref** (`string`): Where that button leads; without it, it is a plain button.
- **onMerge** (`() => void`): Called once the pull request is merged.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer plays the reader’s part: creates, resolves, reads the checks, merges, and starts over.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root.

## Accessibility

- The card is a section named by its title; the commits are an ordered list, each row read as its message, branch, hash and author. The graph is decorative (aria-hidden), and the decoding words have a hidden copy of the real text.
- Create PR, Resolve conflicts, Merge and Open PR are real buttons; the work under way shows as a disabled button. Where the pull request stands is announced in a polite live region.
- The conflicts link is a button (aria-expanded) that opens the conflicting lines on hover, focus or click; the checks are buttons whose tooltips are their descriptions. Escape closes what is open.
- The disclosure over the card is a button that lists the steps the agent took; the diff stat lists the changed files.
- Under reduced motion nothing decodes, spins, pulses or streams, and the demo never plays.

## Source

### merge-graph-card/merge-graph-card.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import {
  Fragment,
  useCallback,
  useEffect,
  useEffectEvent,
  useId,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import { DemoPointer, runDemo, type Point } from './demo-pointer';
import { pathsOf, Slice } from './graph';
import { Bars, Icon, Spinner } from './icons';
import {
  apply,
  CREATE,
  graphOf,
  INITIAL,
  MERGE,
  RESOLVE,
  snapshot,
  TESTS,
  type Model,
  type Row,
  type StartStage,
  type Step,
} from './model';
import { Scramble } from './scramble';
import { useAutoplay } from './use-autoplay';
import styles from './merge-graph-card.module.css';

/*
 * Merge graph card: an agent's chat turn that answers "Create PR" with a card it builds as it
 * works. The card is a commit graph of the branch: lanes of dots joined by curves (the branch,
 * a colleague's branch and the base), one row per commit with its message, a tinted branch pill,
 * its short hash and who made it when.
 *
 * - The agent's work is a script of small changes to one model (model.ts): commit the last
 *   changes, push, open the pull request, fetch the base, find that it moved on and conflicts.
 *   Each change is a row that decodes into place, a node that changes mark, a line under the
 *   card that says what is happening. The reader settles it: Resolve conflicts merges the base
 *   in, conflict by conflict, runs the tests and turns the card green; Merge turns it purple.
 * - The graph is drawn row by row (graph.tsx): each row's SVG shows its band of the whole graph,
 *   so a row that arrives opens up with the lanes running through it.
 * - The script's clock only runs while the card is on screen and the tab is visible.
 * - While on screen and untouched, a demo pointer plays the reader's part: it creates the pull
 *   request, looks at the conflicts, resolves them, reads the checks and merges; then it starts
 *   over. Any real input hands the card to the reader.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

/** Who changed the same lines on the base, and what each side wrote. */
const AUTHOR = 'Lena Marsh';
const CONFLICTS = [
  {
    file: 'middleware.ts',
    line: 18,
    theirs: 'const session = await readSession(req, keys)',
    ours: 'if (nearExpiry(session)) await refresh(session)',
  },
  {
    file: 'auth.ts',
    line: 42,
    theirs: 'return verify(token, keys.current)',
    ours: 'return refreshIfNear(token, 5 * MINUTE)',
  },
  {
    file: 'auth.ts',
    line: 57,
    theirs: 'const ttl = keys.current.ttl',
    ours: 'const ttl = session.expiresAt - Date.now()',
  },
];

/** The checks of a green branch, and what each one's tip says. */
const CHECKS = [
  {
    id: 'tests',
    name: 'Tests',
    result: `${TESTS} passed`,
    tip: `unit · ${TESTS} passed, 0 failed`,
    took: '38s',
  },
  { id: 'lint', name: 'Lint', result: 'passed', tip: 'lint · no problems', took: '9s' },
  { id: 'types', name: 'Types', result: 'passed', tip: 'typecheck · no errors', took: '21s' },
  { id: 'preview', name: 'Preview', result: 'ready', tip: '', took: '1m 4s' },
] as const;

/** The changed files, behind the diff stat. */
const FILES = [
  { name: 'middleware.ts', added: 214, removed: 21 },
  { name: 'auth.ts', added: 161, removed: 12 },
  { name: 'session.test.ts', added: 98, removed: 0 },
  { name: 'session.ts', added: 9, removed: 4 },
];
const ADDED = FILES.reduce((sum, file) => sum + file.added, 0);
const REMOVED = FILES.reduce((sum, file) => sum + file.removed, 0);

const EFFORTS = ['Low', 'Medium', 'High', 'Max'];

/** A stopped script's pending pause rejects with this. */
class Halt extends Error {}

/** Polite announcements of where the pull request stands. */
const says = (stage: Model['stage'], number: number, base: string) =>
  ({
    idle: '',
    sent: 'Creating the pull request.',
    working: 'Creating the pull request.',
    conflicts: `Pull request #${number} is open, with 3 conflicts with ${base}.`,
    resolving: 'Resolving the conflicts.',
    ready: 'All checks passed. Ready to merge.',
    merging: 'Merging.',
    merged: `Merged into ${base}.`,
  })[stage];

export function MergeGraphCard({
  title = 'Refresh sessions in the auth middleware',
  number = 128,
  branch = 'feat/session-refresh',
  base = 'main',
  sideBranch = 'feat/checkout-analytics',
  repository = 'harbor-web',
  defaultStage = 'idle',
  openLabel = 'Open PR',
  prHref,
  onMerge,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The pull request's title, on the card. */
  title?: string;
  /** Its number. */
  number?: number;
  /** The branch the agent works on, and the base it merges into. */
  branch?: string;
  base?: string;
  /** A colleague's branch, off the base, that runs alongside. */
  sideBranch?: string;
  /** The folder chip in the composer. */
  repository?: string;
  /** Where the turn starts, already drawn: before "Create PR", at the conflicts, green, or merged. */
  defaultStage?: StartStage;
  /** The button of a merged card. */
  openLabel?: string;
  /** Where that button leads; without it, it is a button that does nothing more. */
  prHref?: string;
  /** Called once the pull request is merged. */
  onMerge?: () => void;
  /** While on screen and untouched, a demo pointer plays the reader's part, over and over. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take, visible } = useAutoplay<HTMLDivElement>(6000);
  const start = useMemo(() => snapshot(defaultStage), [defaultStage]);
  const [model, setModel] = useState<Model>(start);
  const current = useRef(model);
  /** A turn drawn at once doesn't play its entrances. */
  const [still, setStill] = useState(defaultStage !== 'idle');
  const [request, setRequest] = useState('Create PR');
  const [draft, setDraft] = useState('');
  const [fading, setFading] = useState(false);
  /** What the reader points at or focuses (a popover or a tip shows), and what the demo does. */
  const [peek, setPeek] = useState<string | null>(null);
  const [demoPeek, setDemoPeek] = useState<string | null>(null);
  const [pinned, setPinned] = useState(false);
  const [logOpen, setLogOpen] = useState(false);
  const [filesOpen, setFilesOpen] = useState(false);
  const [place, setPlace] = useState<'local' | 'cloud'>('local');
  const [effort, setEffort] = useState(1);
  const pointer = useRef<HTMLSpanElement>(null);
  const ids = useId();

  /* The script's clock: a pause counts down only while the card is on screen. */
  const generation = useRef(0);
  const awake = useRef(true);
  awake.current = visible;
  const sleep = useCallback((ms: number) => {
    const mine = generation.current;
    return new Promise<void>((resolve, reject) => {
      let left = ms;
      const timer = window.setInterval(() => {
        if (generation.current !== mine) {
          window.clearInterval(timer);
          reject(new Halt());
          return;
        }
        if (awake.current) left -= 50;
        if (left <= 0) {
          window.clearInterval(timer);
          resolve();
        }
      }, 50);
    });
  }, []);
  useEffect(
    () => () => {
      generation.current += 1;
    },
    [],
  );

  const commit = useCallback((patch: Partial<Model>) => {
    current.current = apply(current.current, patch);
    setModel(current.current);
  }, []);

  /** Plays steps; true once all have played, false if a reset stopped them. */
  const play = useCallback(
    async (steps: Step[]) => {
      try {
        for (const [pause, patch] of steps) {
          if (pause) await sleep(pause);
          commit(patch);
          if (patch.thinking) {
            // The disclosure counts the seconds while the agent thinks.
            void (async () => {
              try {
                while (current.current.thinking) {
                  await sleep(1000);
                  if (current.current.thinking) commit({ seconds: current.current.seconds + 1 });
                }
              } catch {}
            })();
          }
        }
        return true;
      } catch (error) {
        if (error instanceof Halt) return false;
        throw error;
      }
    },
    [commit, sleep],
  );

  const reset = useCallback(() => {
    generation.current += 1;
    current.current = INITIAL;
    setModel(INITIAL);
    setPinned(false);
    setLogOpen(false);
    setFading(false);
  }, []);

  const create = useCallback(
    (text?: string) => {
      const stage = current.current.stage;
      if (stage !== 'idle' && stage !== 'merged') return;
      if (stage === 'merged') reset();
      setStill(false);
      setRequest(text?.trim() || 'Create PR');
      void play(CREATE);
    },
    [play, reset],
  );
  const resolve = useCallback(() => {
    if (current.current.stage !== 'conflicts') return;
    setStill(false);
    setPinned(false);
    setPeek(null);
    void play(RESOLVE);
  }, [play]);
  const onMerged = useRef(onMerge);
  useEffect(() => {
    onMerged.current = onMerge;
  });
  const merge = useCallback(() => {
    if (current.current.stage !== 'ready') return;
    setStill(false);
    setPeek(null);
    void play(MERGE).then((done) => done && onMerged.current?.());
  }, [play]);

  // Says where the pull request stands, politely.
  const message = says(model.stage, number, base);

  /* The demo: the pointer plays the reader's part. */
  const demo = autoplay && playing;
  const act = useEffectEvent((what: 'create' | 'resolve' | 'merge' | 'restart') => {
    if (what === 'create') create();
    else if (what === 'resolve') resolve();
    else if (what === 'merge') merge();
    else {
      setFading(true);
      window.setTimeout(reset, 380);
    }
  });
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!demo || !stage || !arrow) return;
    const stop = runDemo(stage, arrow, async ({ wait, glide, glyph }) => {
      const find = (selector: string) => stage.querySelector<HTMLElement>(selector);
      /** A point of an element, in the stage's own px, as shares of its box. */
      const spot = (element: HTMLElement, dx = 0.5, dy = 0.5): Point => {
        const frame = stage.getBoundingClientRect();
        const scale = frame.width / stage.offsetWidth || 1;
        const rect = element.getBoundingClientRect();
        return {
          x: (rect.left + rect.width * dx - frame.left) / scale,
          y: (rect.top + rect.height * dy - frame.top) / scale,
        };
      };
      const press = async (selector: string, what: Parameters<typeof act>[0]) => {
        const element = find(selector);
        if (!element) return;
        glyph('arrow');
        await glide(spot(element, 0.55, 0.6), 700);
        glyph('hand');
        await wait(260);
        element.dataset.pressed = 'true';
        await wait(140);
        delete element.dataset.pressed;
        act(what);
        await wait(320);
        glyph('arrow');
      };
      /** Moves out of the way, to the left of the card. */
      const aside = async () => {
        const card = find('[data-part="card"]') ?? find('[data-part="composer"]');
        if (card) await glide(spot(card, -0.04, 0.72), 900);
      };
      for (;;) {
        const now = current.current;
        if (now.stage === 'idle') {
          await wait(700);
          await press('[data-part="create-chip"]', 'create');
          await aside();
        } else if (now.stage === 'conflicts' && now.paragraph) {
          await wait(1700);
          const link = find('[data-part="conflicts-link"]');
          if (link) {
            await glide(spot(link, 0.45, 0.7), 800);
            setDemoPeek('conflicts');
            await wait(2300);
            setDemoPeek(null);
          }
          await press('[data-part="action"]', 'resolve');
          await wait(200);
        } else if (now.stage === 'ready') {
          await wait(600);
          for (const check of CHECKS) {
            const chip = find(`[data-part="check"][data-check="${check.id}"]`);
            if (!chip) continue;
            await glide(spot(chip, 0.4, 0.7), 520);
            setDemoPeek(check.id);
            await wait(950);
            setDemoPeek(null);
          }
          await press('[data-part="action"]', 'merge');
          await wait(400);
          await aside();
        } else if (now.stage === 'merged') {
          await wait(4200);
          act('restart');
          await wait(900);
        } else await wait(150);
      }
    });
    return () => {
      stop();
      setDemoPeek(null);
    };
  }, [demo, root]);

  const names = { branch, base, side: sideBranch };
  /** The script's lines name the base "main": they take the base's own name. */
  const named = (text: string) => (base === 'main' ? text : text.replace(/\bmain\b/g, base));
  const { rows, edges } = graphOf(model, names);
  const paths = pathsOf(rows, edges);
  const shown = demoPeek ?? peek;
  const conflictsShown = model.stage === 'conflicts' && (pinned || shown === 'conflicts');
  const total = model.left[0] + model.left[1];

  const hover = (id: string) => ({
    onPointerEnter: (event: ReactPointerEvent) => {
      if (event.pointerType !== 'touch') setPeek(id);
    },
    onPointerLeave: () => setPeek((now) => (now === id ? null : now)),
    onFocus: () => setPeek(id),
    onBlur: () => setPeek((now) => (now === id ? null : now)),
  });

  // Escape closes what is open: the conflicts, a tip, the files.
  const open = pinned || peek !== null || filesOpen;
  useEffect(() => {
    if (!open) return;
    const close = (event: KeyboardEvent) => {
      if (event.key !== 'Escape') return;
      setPinned(false);
      setPeek(null);
      setFilesOpen(false);
    };
    document.addEventListener('keydown', close);
    return () => document.removeEventListener('keydown', close);
  }, [open]);

  const submit = () => {
    const stage = current.current.stage;
    if (stage !== 'idle' && stage !== 'merged') return;
    create(draft || 'Create PR');
    setDraft('');
  };
  const canSend = model.stage === 'idle' || model.stage === 'merged';

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="stage"
      data-color-scheme={colorScheme}
      data-still={still || undefined}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onPointerMove={(event) => {
        if (event.pointerType !== 'touch') take();
      }}
    >
      <div className={styles.column}>
        <div className={styles.thread} data-part="thread" data-fading={fading || undefined}>
          {model.bubble && (
            <p className={styles.bubble} data-part="bubble">
              {request}
            </p>
          )}

          {model.card && (
            <div className={styles.turn} data-part="turn">
              <div className={styles.disclosure} data-part="disclosure">
                <button
                  type="button"
                  className={styles.disclosureButton}
                  aria-expanded={logOpen}
                  aria-controls={`${ids}-log`}
                  onClick={() => setLogOpen((open) => !open)}
                >
                  <Icon name="chevron-right" className={styles.chevron} />
                  {model.thinking ? (
                    <span className={styles.thinking}>
                      <span className={styles.shimmer}>Thinking</span>
                      <span className={styles.seconds}>{model.seconds}s</span>
                    </span>
                  ) : (
                    <span>Worked for {model.seconds}s</span>
                  )}
                </button>
                <ol id={`${ids}-log`} className={styles.log} hidden={!logOpen}>
                  {model.log.map((line, index) => (
                    <li key={index}>{named(line)}</li>
                  ))}
                </ol>
              </div>

              <section
                className={styles.card}
                data-part="card"
                data-stage={model.stage}
                aria-labelledby={`${ids}-title`}
              >
                <header className={styles.cardHead}>
                  <h3 id={`${ids}-title`} className={styles.title} data-part="title">
                    {title}
                    {model.opened && <span className={styles.number}> #{number}</span>}
                  </h3>
                  {model.opened && (
                    <span
                      key={model.merge === 'merged' ? 'merged' : 'open'}
                      className={styles.prStatus}
                      data-part="pr-status"
                      data-merged={model.merge === 'merged' || undefined}
                    >
                      {model.merge === 'merged' ? 'Merged' : 'Open'}
                    </span>
                  )}
                </header>

                <ol className={styles.rows} aria-label="Commits" data-part="commits">
                  {rows.map((row, index) => (
                    <CommitRow
                      key={row.id}
                      row={row}
                      index={index}
                      count={rows.length}
                      paths={paths}
                      still={still}
                    />
                  ))}
                </ol>

                <div className={styles.checksWrap} data-open={model.checks || undefined}>
                  <ul className={styles.checks} aria-label="Checks" data-part="checks">
                    {CHECKS.map((check, index) => {
                      const tip = `${ids}-tip-${check.id}`;
                      return (
                        <li
                          key={check.id}
                          className={styles.checkItem}
                          style={{ '--i': index } as CSSProperties}
                        >
                          <button
                            type="button"
                            className={styles.check}
                            data-part="check"
                            data-check={check.id}
                            aria-describedby={tip}
                            tabIndex={model.checks ? 0 : -1}
                            {...hover(check.id)}
                          >
                            <Icon name="check" className={styles.checkIcon} />
                            <span className={styles.checkName}>{check.name}</span>
                            <span className={styles.checkResult}>{check.result}</span>
                          </button>
                          <span
                            id={tip}
                            role="tooltip"
                            className={styles.tip}
                            data-shown={(model.checks && shown === check.id) || undefined}
                          >
                            {check.tip ||
                              `Deployed to ${branch.split('/').pop()}.preview.example.com`}
                            <span className={styles.tipTime}>{check.took}</span>
                          </span>
                        </li>
                      );
                    })}
                  </ul>
                </div>

                {model.footer && (
                  <div className={styles.footer} data-part="footer" data-kind={model.footer.kind}>
                    <p
                      key={model.footer.text}
                      className={styles.footerText}
                      data-part="footer-text"
                    >
                      {model.footer.kind === 'busy' && (
                        <>
                          <Spinner className={styles.footerSpinner} />
                          <span className={styles.shimmer}>{named(model.footer.text)}</span>
                        </>
                      )}
                      {model.footer.kind === 'conflicts' && (
                        <>
                          <Icon name="warning" className={styles.footerIcon} />
                          <button
                            type="button"
                            className={styles.conflictsLink}
                            data-part="conflicts-link"
                            aria-expanded={conflictsShown}
                            aria-controls={`${ids}-conflicts`}
                            onClick={() => setPinned((open) => !open)}
                            {...hover('conflicts')}
                          >
                            {total} conflict{total === 1 ? '' : 's'}
                          </button>
                          <span>{`\u00a0${named(model.footer.text)}`}</span>
                        </>
                      )}
                      {model.footer.kind === 'ready' && (
                        <>
                          <Icon name="check" className={styles.footerIcon} />
                          <span>{named(model.footer.text)}</span>
                        </>
                      )}
                      {model.footer.kind === 'merged' && (
                        <>
                          <Icon name="merge" className={styles.footerIcon} />
                          <span>{named(model.footer.text)}</span>
                        </>
                      )}
                    </p>

                    {model.footer.action && (
                      <FooterAction
                        action={model.footer.action}
                        label={openLabel}
                        href={prHref}
                        onResolve={resolve}
                        onMerge={merge}
                      />
                    )}

                    {model.stage === 'conflicts' && (
                      <div
                        id={`${ids}-conflicts`}
                        className={styles.popover}
                        data-part="conflicts-popover"
                        data-shown={conflictsShown || undefined}
                        role="group"
                        aria-label="Conflicting changes"
                      >
                        <p className={styles.popoverHead}>
                          <strong>{AUTHOR}</strong> changed the same lines on {base}
                        </p>
                        {CONFLICTS.map((conflict, index) => (
                          <div key={index} className={styles.conflict}>
                            <p className={styles.conflictFile}>
                              <strong>{conflict.file}</strong> line {conflict.line}
                            </p>
                            <p className={styles.codeLine} data-side="theirs">
                              <span className={styles.codeWho}>{AUTHOR.split(' ')[0]}</span>
                              <code>{conflict.theirs}</code>
                            </p>
                            <p className={styles.codeLine} data-side="ours">
                              <span className={styles.codeWho}>You</span>
                              <code>{conflict.ours}</code>
                            </p>
                          </div>
                        ))}
                      </div>
                    )}
                  </div>
                )}
              </section>
            </div>
          )}

          {model.paragraph && (
            <p className={styles.paragraph} data-part="paragraph">
              <Words
                parts={[
                  ['PR '],
                  [`#${number}`, 'b'],
                  [' is open, but '],
                  [base, 'c'],
                  [
                    ` moved on while we worked: ${AUTHOR.split(' ')[0]}’s session key rotation changed the same lines in `,
                  ],
                  ['auth.ts', 'c'],
                  [' and '],
                  ['middleware.ts', 'c'],
                  ['. I can merge '],
                  [base, 'c'],
                  [' in and resolve them.'],
                ]}
              />
            </p>
          )}
        </div>

        <div className={styles.chips} data-part="chips">
          <StatusChip
            chip={model.chip}
            number={number}
            conflicts={total}
            onCreate={() => create()}
          />
          {model.chip !== 'merged' && (
            <div className={styles.diffWrap}>
              <button
                type="button"
                className={styles.chip}
                data-part="diffstat"
                aria-expanded={filesOpen}
                aria-controls={`${ids}-files`}
                aria-label={`${FILES.length} files changed, ${ADDED} lines added, ${REMOVED} removed`}
                onClick={() => setFilesOpen((open) => !open)}
              >
                <span className={styles.added}>+{ADDED}</span>
                <span className={styles.removed}>−{REMOVED}</span>
                <span className={styles.blocks} aria-hidden="true">
                  {['add', 'add', 'add', 'remove', 'none'].map((kind, index) => (
                    <span key={index} data-kind={kind} />
                  ))}
                </span>
                <Icon name="chevron-down" className={styles.chipChevron} />
              </button>
              <ul id={`${ids}-files`} className={styles.files} hidden={!filesOpen}>
                {FILES.map((file) => (
                  <li key={file.name}>
                    <span className={styles.fileName}>{file.name}</span>
                    <span className={styles.added}>+{file.added}</span>
                    <span className={styles.removed}>−{file.removed}</span>
                  </li>
                ))}
              </ul>
            </div>
          )}
        </div>

        <form
          className={styles.composer}
          data-part="composer"
          onSubmit={(event) => {
            event.preventDefault();
            submit();
          }}
        >
          <textarea
            className={styles.input}
            rows={1}
            value={draft}
            placeholder="Ask anything, or type / for skills"
            aria-label="Message"
            onChange={(event) => setDraft(event.target.value)}
            onKeyDown={(event) => {
              if (event.key === 'Enter' && !event.shiftKey) {
                event.preventDefault();
                submit();
              }
            }}
          />
          <div className={styles.toolbar}>
            <button type="button" className={styles.round} aria-label="Attach">
              <Icon name="plus" />
            </button>
            <button type="button" className={styles.pill} aria-label={`Folder: ${repository}`}>
              <Icon name="folder" />
              <span className={styles.pillLabel}>{repository}</span>
            </button>
            <div className={styles.segments} role="radiogroup" aria-label="Where it runs">
              <button
                type="button"
                role="radio"
                aria-checked={place === 'local'}
                className={styles.segment}
                onClick={() => setPlace('local')}
              >
                <Icon name="laptop" />
                <span className={styles.segmentLabel}>Local</span>
              </button>
              <button
                type="button"
                role="radio"
                aria-checked={place === 'cloud'}
                aria-label="Cloud"
                className={styles.segment}
                onClick={() => setPlace('cloud')}
              >
                <Icon name="cloud" />
              </button>
            </div>
            <span className={styles.spacer} />
            <button
              type="button"
              className={styles.effort}
              aria-label={`Effort: ${EFFORTS[effort]}`}
              onClick={() => setEffort((level) => (level + 1) % EFFORTS.length)}
            >
              <Bars level={effort + 1} />
              <span className={styles.effortLabel}>{EFFORTS[effort]}</span>
            </button>
            <button type="button" className={styles.mic} aria-label="Dictate">
              <Icon name="mic" />
            </button>
            <button
              type="submit"
              className={styles.send}
              data-part="send"
              aria-label="Send"
              aria-disabled={!canSend || undefined}
            >
              <Icon name="arrow-up" />
            </button>
          </div>
        </form>
      </div>

      <div className={styles.pointerLayer} aria-hidden="true">
        <DemoPointer
          ref={pointer}
          className={styles.pointer}
          arrowClass={styles.arrow}
          handClass={styles.hand}
        />
      </div>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}

/** One commit: its band of the graph, its message and pills, and its hash and author. */
function CommitRow({
  row,
  index,
  count,
  paths,
  still,
}: {
  row: Row;
  index: number;
  count: number;
  paths: ReturnType<typeof pathsOf>;
  still: boolean;
}) {
  return (
    <li
      className={styles.row}
      data-part="commit"
      data-enter={row.enter}
      data-tone={row.tone}
      data-flash={row.flash}
      style={{ '--i': index, '--from-end': count - 1 - index } as CSSProperties}
    >
      <Slice row={row} index={index} paths={paths} />
      <span className={styles.message}>
        <Scramble
          text={row.text}
          active={!!row.scramble && !still}
          className={styles.messageText}
        />
        {row.pill && (
          <span className={styles.branchPill} data-part="branch" data-hue={row.pill.hue}>
            {row.pill.label}
          </span>
        )}
        {row.badge && (
          <span
            key={row.badge.kind}
            className={styles.badge}
            data-part="badge"
            data-kind={row.badge.kind}
            aria-label={
              row.badge.kind === 'count'
                ? `${row.badge.count} conflict${row.badge.count === 1 ? '' : 's'}`
                : 'resolved'
            }
          >
            {row.badge.kind === 'count' ? row.badge.count : <Icon name="check" />}
          </span>
        )}
      </span>
      <span className={styles.meta} data-part="meta">
        {row.status ? (
          <span key={row.status.text} className={styles.status} data-hue={row.status.hue}>
            {row.status.text}
          </span>
        ) : (
          <>
            <span className={styles.hash}>{row.hash}</span>
            <span className={styles.who}>
              {row.who} · {row.when}
            </span>
          </>
        )}
      </span>
      {row.progress !== undefined && (
        <span
          className={styles.progress}
          style={{ '--progress': row.progress } as CSSProperties}
          aria-hidden="true"
        />
      )}
    </li>
  );
}

/** The card's button: resolve, merge, the work under way, or the pull request itself. */
function FooterAction({
  action,
  label,
  href,
  onResolve,
  onMerge,
}: {
  action: NonNullable<Model['footer']>['action'];
  label: string;
  href?: string;
  onResolve: () => void;
  onMerge: () => void;
}) {
  if (action === 'resolve' || action === 'merge')
    return (
      <button
        type="button"
        className={styles.action}
        data-part="action"
        data-tone="accent"
        onClick={action === 'resolve' ? onResolve : onMerge}
      >
        {action === 'merge' && <Icon name="merge" />}
        {action === 'resolve' ? 'Resolve conflicts' : 'Merge'}
      </button>
    );
  if (action === 'open')
    return href ? (
      <a className={styles.action} data-part="action" data-tone="plain" href={href}>
        <Icon name="open" />
        {label}
      </a>
    ) : (
      <button type="button" className={styles.action} data-part="action" data-tone="plain">
        <Icon name="open" />
        {label}
      </button>
    );
  return (
    <button
      type="button"
      className={styles.action}
      data-part="action"
      data-tone="busy"
      aria-disabled="true"
    >
      <Spinner />
      {action === 'resolving' ? 'Resolving' : 'Merging'}
    </button>
  );
}

/** The chip over the composer: the request to make, then where the pull request stands. */
function StatusChip({
  chip,
  number,
  conflicts,
  onCreate,
}: {
  chip: Model['chip'];
  number: number;
  conflicts: number;
  onCreate: () => void;
}) {
  if (chip === 'create')
    return (
      <button type="button" className={styles.chip} data-part="create-chip" onClick={onCreate}>
        Create PR
      </button>
    );
  const busy = chip === 'creating' || chip === 'resolving' || chip === 'merging';
  return (
    <span key={chip} className={styles.chip} data-part="status-chip" data-chip={chip}>
      {busy ? (
        <Spinner className={styles.chipSpinner} />
      ) : chip === 'merged' ? (
        <Icon name="merge" className={styles.chipIcon} />
      ) : (
        <span className={styles.chipDot} />
      )}
      {chip !== 'creating' && <span className={styles.chipNumber}>#{number}</span>}
      <span className={styles.chipState}>
        {chip === 'creating'
          ? 'Creating PR'
          : chip === 'conflicts'
            ? `${conflicts} conflict${conflicts === 1 ? '' : 's'}`
            : chip === 'resolving'
              ? 'Resolving'
              : chip === 'ready'
                ? 'Ready to merge'
                : chip === 'merging'
                  ? 'Merging'
                  : 'Merged'}
      </span>
    </span>
  );
}

/** The assistant's words, streaming in one by one; code and bold runs kept whole. */
function Words({ parts }: { parts: ([string] | [string, 'b' | 'c'])[] }) {
  let order = 0;
  return (
    <>
      {parts.map(([text, kind], index) => {
        if (kind) {
          const style = { '--w': order++ } as CSSProperties;
          return kind === 'b' ? (
            <strong key={index} className={styles.word} style={style}>
              {text}
            </strong>
          ) : (
            <code key={index} className={`${styles.word} ${styles.code}`} style={style}>
              {text}
            </code>
          );
        }
        return (
          <Fragment key={index}>
            {text.split(/(\s+)/).map((piece, at) =>
              piece.trim() ? (
                <span key={at} className={styles.word} style={{ '--w': order++ } as CSSProperties}>
                  {piece}
                </span>
              ) : (
                piece
              ),
            )}
          </Fragment>
        );
      })}
    </>
  );
}
```

### merge-graph-card/merge-graph-card.module.css

```css
/* Merge graph card (merge-graph-card.tsx): a chat turn on a near-white stage, or at night a
   near-black one. A 640 px column holds, bottom up, the composer, the status chips, and the
   thread: the request, the disclosure, the card and the answer. The thread grows upwards from
   the composer, as a chat does.

   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes
   the color-scheme it inherits from the page (light unless the page says otherwise), or the one
   its colorScheme prop sets (data-color-scheme).

   Motion. Rows that arrive later open up (their height, 30 px, once); everything else moves by
   opacity and transforms: entrances, the spinners, the halo of a node, the shimmer of the words
   in progress (a moving background clipped to the text). */

.root {
  /* The stage, and the surfaces on it. */
  --surface: #fdfdfd;
  --raised: #fff;
  --fill: #f3f3f3;
  --fill-2: #ececec;
  --edge: #ebebeb;
  --edge-2: #e3e3e3;
  /* Words: the strongest, the meta, the quiet ones (others' commits), the faint (hashes). */
  --ink: #1a1a1a;
  --ink-2: #6b6b6b;
  --quiet: #7c7c7c;
  --faint: #a0a0a0;
  --shine: #c6c6c6;
  /* The lanes: the base, the branch, a colleague's branch. */
  --base: #bdbdbd;
  --branch: #4b8cee;
  --side: #20cfac;
  --branch-ink: #2350bf;
  --branch-tint: #ebf1fd;
  --side-ink: #12856a;
  --side-tint: #e7f8f3;
  /* What the merge goes through: conflicts, green, merged. */
  --conflict: #eca713;
  --conflict-ink: #a9580b;
  --conflict-tint: #fdf4e8;
  --conflict-edge: #f0d49b;
  --ready: #2ec35a;
  --ready-ink: #219140;
  --ready-tint: #e9f7ee;
  --merged: #9157ec;
  --merged-ink: #7019d2;
  --merged-tint: #f3edfd;
  --removed: #c42a40;
  /* The buttons that act: tinted blue. */
  --accent: #2a66e3;
  --accent-ink: #2551c2;
  --accent-tint: #e6edfc;
  --accent-tint-2: #d9e4fb;
  /* Lifts. */
  --shadow: rgb(0 0 0 / 0.04);
  --pop-shadow: rgb(0 0 0 / 0.1);
  /* The same on both schemes: the focus ring, and the demo's pointer. */
  --focus: #387bff;
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);

  --sans: Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
  --mono: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace;

  position: relative;
  container-type: inline-size;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  width: 100%;
  min-height: 880px;
  padding: 28px 16px 24px;
  overflow: clip;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--edge);
  color: var(--ink);
  font-family: var(--sans);
  font-size: 15px;
  line-height: 20px;
  letter-spacing: 0.005em;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#fdfdfd, #141415);
    --raised: light-dark(#fff, #1c1c1e);
    --fill: light-dark(#f3f3f3, #232325);
    --fill-2: light-dark(#ececec, #2c2c2f);
    --edge: light-dark(#ebebeb, #28282b);
    --edge-2: light-dark(#e3e3e3, #333336);
    --ink: light-dark(#1a1a1a, #ededf0);
    --ink-2: light-dark(#6b6b6b, #a2a2a8);
    --quiet: light-dark(#7c7c7c, #8e8e95);
    --faint: light-dark(#a0a0a0, #6c6c72);
    --shine: light-dark(#c6c6c6, #5a5a60);
    --base: light-dark(#bdbdbd, #57575d);
    --branch: light-dark(#4b8cee, #5b9bff);
    --side: light-dark(#20cfac, #2fd6b4);
    --branch-ink: light-dark(#2350bf, #92b9ff);
    --branch-tint: light-dark(#ebf1fd, #17243d);
    --side-ink: light-dark(#12856a, #52dcbd);
    --side-tint: light-dark(#e7f8f3, #11302a);
    --conflict: light-dark(#eca713, #f0b42c);
    --conflict-ink: light-dark(#a9580b, #f2a54c);
    --conflict-tint: light-dark(#fdf4e8, #2e2212);
    --conflict-edge: light-dark(#f0d49b, #5b4317);
    --ready: light-dark(#2ec35a, #3ad06a);
    --ready-ink: light-dark(#219140, #56d37e);
    --ready-tint: light-dark(#e9f7ee, #14301d);
    --merged: light-dark(#9157ec, #a77cff);
    --merged-ink: light-dark(#7019d2, #c3a2ff);
    --merged-tint: light-dark(#f3edfd, #261b3d);
    --removed: light-dark(#c42a40, #ff6f80);
    --accent: light-dark(#2a66e3, #3b78f0);
    --accent-ink: light-dark(#2551c2, #9cbdff);
    --accent-tint: light-dark(#e6edfc, #1b2a48);
    --accent-tint-2: light-dark(#d9e4fb, #22355a);
    --shadow: light-dark(rgb(0 0 0 / 0.04), rgb(0 0 0 / 0.3));
    --pop-shadow: light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.5));
  }
}
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

:where(.root) button,
:where(.root) textarea {
  font: inherit;
  color: inherit;
}
:where(.root) button {
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
}
.root :focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
:where(.root) p,
:where(.root) h3,
:where(.root) ol,
:where(.root) ul {
  margin: 0;
  padding: 0;
}
:where(.root) ol,
:where(.root) ul {
  list-style: none;
}

.icon {
  display: block;
  flex: none;
  width: 16px;
  height: 16px;
}

.column {
  display: flex;
  flex-direction: column;
  width: 100%;
  max-width: 640px;
  margin: 0 auto;
}

/* The thread, over the composer. */
.thread {
  display: flex;
  flex-direction: column;
  transition: opacity 0.35s ease;
}
.thread[data-fading] {
  opacity: 0;
}

.bubble {
  align-self: flex-end;
  max-width: 80%;
  margin-bottom: 26px;
  padding: 11px 16px;
  border-radius: 21px;
  background: var(--fill);
  font-size: 15px;
  line-height: 20px;
  animation: rise 0.32s cubic-bezier(0.2, 0.8, 0.2, 1) both;
}

.turn {
  display: flex;
  flex-direction: column;
  animation: rise 0.36s cubic-bezier(0.2, 0.8, 0.2, 1) both;
}

/* "Thinking 3s", then "Worked for 5s": it opens on the steps the agent took. */
.disclosure {
  margin-bottom: 20px;
}
.disclosureButton {
  display: flex;
  align-items: center;
  height: 22px;
  border-radius: 6px;
  color: var(--ink-2);
  font-size: 14px;
}
.chevron {
  width: 14px;
  height: 14px;
  margin: 0 26px 0 3px;
  color: var(--faint);
  transition: transform 0.2s ease;
}
.disclosureButton[aria-expanded='true'] .chevron {
  transform: rotate(90deg);
}
.thinking {
  display: flex;
  gap: 8px;
  color: var(--ink);
}
.seconds {
  color: var(--faint);
  font-variant-numeric: tabular-nums;
}
.log {
  margin: 8px 0 0 35px;
  color: var(--ink-2);
  font-size: 14px;
  line-height: 22px;
}

/* The words of work in progress, with a light passing over them. */
.shimmer {
  background: linear-gradient(90deg, var(--ink) 0 35%, var(--shine) 50%, var(--ink) 65% 100%) 0 0 /
    250% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  animation: shimmer 1.8s linear infinite;
}

/* The card. */
.card {
  position: relative;
  padding: 15px 16px 14px;
  border: 1px solid var(--edge);
  border-radius: 14px;
  background: var(--surface);
  box-shadow: 0 1px 3px var(--shadow);
}
.cardHead {
  display: flex;
  align-items: baseline;
  gap: 12px;
  margin-bottom: 6.5px;
}
.title {
  flex: 1;
  min-width: 0;
  font-size: 15px;
  font-weight: 600;
  line-height: 20px;
  letter-spacing: -0.008em;
}
.number {
  color: var(--ink-2);
  font-weight: 400;
}
.prStatus {
  display: flex;
  align-items: center;
  gap: 7px;
  color: var(--ready-ink);
  font-size: 13px;
  white-space: nowrap;
  animation: fade 0.3s ease both;
}
.prStatus::before {
  content: '';
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: currentColor;
}
.prStatus[data-merged] {
  color: var(--merged-ink);
}

/* The rows: the graph's band, the message with its pills, the hash and author. */
.rows {
  margin: 0 -8px;
}
.row {
  position: relative;
  display: grid;
  grid-template-columns: 61px minmax(0, 1fr) auto;
  align-items: center;
  height: 30px;
  padding: 0 8px;
  overflow: hidden;
  border-radius: 8px;
  font-size: 14px;
  line-height: 20px;
  transition: background-color 0.6s ease;
  transition-delay: calc(var(--i) * 70ms);
}
.row[data-enter='fade'] {
  animation: fadeIn 0.42s ease both;
  animation-delay: calc(var(--from-end) * 70ms + 80ms);
}
.row[data-enter='grow'] {
  animation:
    grow 0.34s cubic-bezier(0.2, 0.8, 0.2, 1) both,
    flash 1.4s ease-out both;
}
.row[data-enter='grow'][data-flash='warm'] {
  --flash: var(--conflict-tint);
}
.row[data-enter='grow'][data-flash='cool'] {
  --flash: var(--fill);
}
.row[data-flash='warm']:first-child {
  /* The merge commit, while it waits: it stays tinted. */
  animation: grow 0.34s cubic-bezier(0.2, 0.8, 0.2, 1) both;
  background: var(--conflict-tint);
}
.row[data-flash='merged'] {
  animation: sweep 1.5s ease both;
  animation-delay: calc(var(--i) * 90ms);
}
.root[data-still] .row,
.root[data-still] .turn,
.root[data-still] .bubble,
.root[data-still] .word,
.root[data-still] .prStatus,
.root[data-still] .footerText {
  animation: none;
}

.graph {
  display: block;
  width: 61px;
  height: 30px;
  overflow: hidden;
}
.edge {
  fill: none;
  stroke: var(--line);
  stroke-width: 1.5;
  stroke-linecap: round;
  --line: color-mix(in srgb, var(--base) 75%, transparent);
  transition: stroke 0.45s ease;
  transition-delay: calc(var(--i) * 90ms);
}
.edge[data-hue='branch'] {
  --line: color-mix(in srgb, var(--branch) 50%, transparent);
}
.edge[data-hue='side'] {
  --line: color-mix(in srgb, var(--side) 50%, transparent);
}
.edge[data-hue='conflict'] {
  --line: var(--conflict);
}
.edge[data-hue='ready'] {
  --line: var(--ready);
}
.edge[data-hue='merged'] {
  --line: color-mix(in srgb, var(--merged) 55%, transparent);
}
.edge[data-dashed] {
  stroke-dasharray: 2 3;
}
.edge[data-hue='base'][data-dashed] {
  --line: color-mix(in srgb, var(--base) 55%, transparent);
}
.edge[data-draw] {
  stroke-dasharray: 1;
  animation: draw 0.55s ease-out both;
}

.node {
  --c: var(--base);
}
.node[data-hue='branch'] {
  --c: var(--branch);
}
.node[data-hue='side'] {
  --c: var(--side);
}
.node[data-hue='conflict'] {
  --c: var(--conflict);
}
.node[data-hue='merged'] {
  --c: var(--merged);
}
.node[data-node='warn'] {
  --c: var(--conflict);
}
.node[data-node='check'] {
  --c: var(--ready);
}
.node[data-node='merged'] {
  --c: var(--merged);
}
.dot {
  fill: var(--c);
  transition: fill 0.45s ease;
  transition-delay: calc(var(--i) * 90ms);
}
.pending {
  fill: var(--surface);
  stroke: var(--faint);
  stroke-width: 1.4;
  stroke-dasharray: 2 2;
  transform-box: fill-box;
  transform-origin: center;
  animation: spin 2.4s linear infinite;
}
.ring {
  fill: var(--surface);
  stroke: var(--c);
  stroke-width: 1.5;
  transition: stroke 0.45s ease;
}
.track {
  fill: none;
  stroke: color-mix(in srgb, var(--c) 22%, transparent);
  stroke-width: 1.4;
}
.arc {
  fill: none;
  stroke: var(--c);
  stroke-width: 1.4;
  stroke-linecap: round;
  stroke-dasharray: 32 68;
  transform-box: fill-box;
  transform-origin: center;
  animation: spin 0.9s linear infinite;
}
.badgeNode {
  transform-box: fill-box;
  transform-origin: center;
  animation: pop 0.38s cubic-bezier(0.3, 1.5, 0.5, 1) both;
}
.disc {
  fill: var(--c);
}
.halo {
  fill: var(--c);
  opacity: 0;
  transform-box: fill-box;
  transform-origin: center;
}
.node[data-node='warn'] .halo {
  animation: halo 1.8s ease-out infinite;
}
.node[data-node='check'] .halo,
.node[data-node='merged'] .halo {
  animation: halo 0.9s ease-out 0.1s 1 both;
}
.glyph {
  fill: none;
  stroke: #fff;
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.node[data-node='check'] .glyph {
  stroke-dasharray: 1;
  animation: draw 0.35s ease-out 0.15s both;
}
.glyphDot {
  fill: #fff;
}

.message {
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
  padding-right: 12px;
}
.messageText {
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  transition: color 0.5s ease;
}
.scramble {
  display: block;
}
.scrambleText {
  white-space: pre;
}
.row[data-tone='own'] .messageText,
.row[data-tone='strong'] .messageText {
  color: var(--ink);
}
.row[data-tone='other'] .messageText,
.row[data-tone='quiet'] .messageText {
  color: var(--quiet);
}
.row[data-tone='conflict'] .messageText {
  color: var(--conflict-ink);
}

.branchPill {
  flex: none;
  padding: 0 5px;
  border-radius: 5px;
  font-family: var(--mono);
  font-size: 11.5px;
  font-weight: 500;
  line-height: 18px;
  letter-spacing: 0;
  white-space: nowrap;
  background: var(--fill);
  color: var(--ink-2);
  transition:
    background-color 0.45s ease,
    color 0.45s ease;
  transition-delay: calc(var(--i) * 90ms);
}
.branchPill[data-hue='branch'] {
  background: var(--branch-tint);
  color: var(--branch-ink);
}
.row[data-flash='merged'] .branchPill[data-hue='branch'] {
  background: var(--merged-tint);
  color: var(--merged-ink);
}
.branchPill[data-hue='side'] {
  background: var(--side-tint);
  color: var(--side-ink);
}

.badge {
  display: grid;
  flex: none;
  place-items: center;
  width: 17px;
  height: 17px;
  border-radius: 50%;
  font-size: 11px;
  font-weight: 500;
  line-height: 1;
  font-variant-numeric: tabular-nums;
  animation: pop 0.32s cubic-bezier(0.3, 1.5, 0.5, 1) both;
}
.badge[data-kind='count'] {
  border: 1px solid var(--conflict-edge);
  background: var(--conflict-tint);
  color: var(--conflict-ink);
}
.badge[data-kind='check'] {
  border: 1px solid color-mix(in srgb, var(--ready) 35%, transparent);
  background: var(--ready-tint);
  color: var(--ready-ink);
}
.badge .icon {
  width: 10px;
  height: 10px;
  stroke-width: 2;
}

.meta {
  display: flex;
  align-items: baseline;
  gap: 6px;
  font-size: 13px;
  white-space: nowrap;
}
.hash {
  color: var(--faint);
  font-family: var(--mono);
  font-size: 12px;
  letter-spacing: 0;
}
.who {
  color: var(--ink-2);
}
.status {
  animation: fade 0.25s ease both;
  font-variant-numeric: tabular-nums;
}
.status[data-hue='quiet'] {
  color: var(--ink-2);
}
.status[data-hue='ink'] {
  color: var(--ink);
}
.status[data-hue='conflict'] {
  color: var(--conflict-ink);
}
.status[data-hue='resolved'] {
  color: color-mix(in srgb, var(--conflict-ink) 70%, var(--surface));
}
.status[data-hue='ready'] {
  color: var(--ready-ink);
}
.status[data-hue='merged'] {
  color: var(--merged-ink);
}

/* The tests' progress, under the merge commit. */
.progress {
  position: absolute;
  right: 8px;
  bottom: 1px;
  left: 69px;
  height: 2px;
  background: var(--fill-2);
}
.progress::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 1px;
  background: var(--ready);
  transform: scaleX(var(--progress));
  transform-origin: left;
  transition: transform 0.18s linear;
}

/* The checks of a green branch: they open under the rows, and close once merged. */
.checksWrap {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.35s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.checksWrap[data-open] {
  grid-template-rows: 1fr;
}
.checks {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  min-height: 0;
  margin: 0 -2px;
  padding: 0 0 0;
  overflow: hidden;
  opacity: 0;
  transition: opacity 0.25s ease;
}
.checksWrap[data-open] .checks {
  padding: 13px 2px 2px;
  overflow: visible;
  opacity: 1;
}
.checkItem {
  position: relative;
}
.check {
  display: flex;
  align-items: center;
  gap: 6px;
  height: 24px;
  padding: 0 10px 0 8px;
  border: 1px solid var(--edge);
  border-radius: 12px;
  font-size: 13.5px;
  white-space: nowrap;
  transition: background-color 0.15s ease;
}
.check:hover,
.check:focus-visible {
  background: var(--fill);
}
.checksWrap[data-open] .check {
  animation: pop 0.3s cubic-bezier(0.2, 0.8, 0.2, 1) both;
  animation-delay: calc(var(--i) * 70ms);
}
.checkIcon {
  width: 12px;
  height: 12px;
  color: var(--ready);
  stroke-width: 2;
}
.checkResult {
  color: var(--quiet);
}
.tip {
  position: absolute;
  bottom: calc(100% + 4px);
  left: 0;
  z-index: 3;
  display: flex;
  gap: 10px;
  padding: 3px 8px;
  border-radius: 7px;
  background: var(--raised);
  box-shadow:
    0 0 0 1px var(--edge),
    0 4px 12px var(--pop-shadow);
  color: var(--ink);
  font-size: 13px;
  line-height: 20px;
  white-space: nowrap;
  opacity: 0;
  transform: translateY(3px);
  pointer-events: none;
  transition:
    opacity 0.15s ease,
    transform 0.15s ease;
}
.tip[data-shown] {
  opacity: 1;
  transform: none;
}
.tipTime {
  color: var(--faint);
}

/* The last line: what is happening, and the button. */
.footer {
  position: relative;
  display: flex;
  align-items: center;
  gap: 12px;
  min-height: 31px;
  margin-top: 11.5px;
}
.footerText {
  display: flex;
  flex: 1;
  align-items: center;
  min-width: 0;
  font-size: 14px;
  white-space: nowrap;
  animation: fade 0.28s ease both;
}
.footerText > :last-child {
  overflow: hidden;
  text-overflow: ellipsis;
}
.footerSpinner {
  margin-right: 10px;
}
.footerIcon {
  margin-right: 8px;
}
.footer[data-kind='conflicts'] .footerIcon {
  color: var(--conflict-ink);
}
.footer[data-kind='ready'] .footerIcon {
  width: 14px;
  height: 14px;
  margin-right: 10px;
  color: var(--ready);
  stroke-width: 1.8;
}
.footer[data-kind='merged'] .footerIcon {
  color: var(--merged);
}
.conflictsLink {
  flex: none;
  color: var(--conflict-ink);
  text-decoration: underline dotted;
  text-decoration-color: color-mix(in srgb, var(--conflict-ink) 55%, transparent);
  text-underline-offset: 4px;
  white-space: pre;
}
.action {
  display: flex;
  flex: none;
  align-items: center;
  gap: 7px;
  height: 31px;
  padding: 0 14px;
  border-radius: 16px;
  font-size: 14px;
  font-weight: 500;
  white-space: nowrap;
  text-decoration: none;
  transition:
    background-color 0.15s ease,
    transform 0.12s ease;
  animation: fade 0.25s ease both;
}
.action[data-tone='accent'] {
  background: var(--accent-tint);
  color: var(--accent-ink);
}
.action[data-tone='accent']:hover,
.action[data-pressed] {
  background: var(--accent-tint-2);
}
.action[data-pressed],
.action:active {
  transform: scale(0.97);
}
.action[data-tone='plain'] {
  background: var(--fill);
  color: var(--ink);
}
.action[data-tone='plain']:hover {
  background: var(--fill-2);
}
.action[data-tone='busy'] {
  background: var(--fill);
  color: var(--ink-2);
  font-weight: 400;
  cursor: default;
}
.action .icon {
  width: 15px;
  height: 15px;
}

/* Who changed the same lines, and both sides of each. */
.popover {
  position: absolute;
  bottom: calc(100% + 6px);
  left: 14px;
  z-index: 3;
  width: min(424px, calc(100% - 14px));
  padding-bottom: 6px;
  border-radius: 12px;
  background: var(--raised);
  box-shadow:
    0 0 0 1px var(--edge),
    0 10px 30px var(--pop-shadow);
  font-size: 14px;
  opacity: 0;
  transform: translateY(4px) scale(0.985);
  transform-origin: 20% 100%;
  pointer-events: none;
  visibility: hidden;
  transition:
    opacity 0.18s ease,
    transform 0.18s ease,
    visibility 0s linear 0.18s;
}
.popover[data-shown] {
  opacity: 1;
  transform: none;
  pointer-events: auto;
  visibility: visible;
  transition:
    opacity 0.18s ease,
    transform 0.18s ease;
}
.popoverHead {
  padding: 9px 14px 9px;
  border-bottom: 1px solid var(--edge);
  color: var(--ink-2);
}
.popoverHead strong,
.conflictFile strong {
  color: var(--ink);
  font-weight: 600;
}
.conflict {
  margin: 0 14px;
  padding: 10px 0 8px;
}
.conflict + .conflict {
  border-top: 1px solid var(--edge);
}
.conflictFile {
  margin-bottom: 4px;
  color: var(--quiet);
}
.codeLine {
  display: flex;
  gap: 10px;
  align-items: baseline;
  margin-left: -6px;
  padding: 2px 0 2px 8px;
  border-left: 2px solid var(--conflict-edge);
  line-height: 20px;
  white-space: nowrap;
  overflow: hidden;
}
.codeLine[data-side='ours'] {
  border-left-color: color-mix(in srgb, var(--branch) 45%, transparent);
}
.codeWho {
  flex: none;
  width: 30px;
  color: var(--quiet);
}
.codeLine code {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  font-family: var(--mono);
  font-size: 12px;
  letter-spacing: 0;
}

/* The answer, word by word. */
.paragraph {
  margin-top: 17px;
  padding-right: 24px;
  font-size: 15px;
  line-height: 24px;
}
.paragraph strong {
  font-weight: 600;
}
.word {
  display: inline-block;
  animation: word 0.42s ease both;
  animation-delay: calc(var(--w) * 26ms);
}
.code {
  padding: 0 5px;
  border-radius: 4px;
  background: var(--fill);
  font-family: var(--mono);
  font-size: 13.5px;
}

/* The chips over the composer. */
.chips {
  position: relative;
  display: flex;
  gap: 6px;
  margin: 32px 0 8px;
}
.chip {
  display: flex;
  align-items: center;
  gap: 7px;
  height: 28px;
  padding: 0 12px;
  border: 1px solid var(--edge);
  border-radius: 14px;
  background: var(--surface);
  color: var(--ink-2);
  font-size: 13px;
  white-space: nowrap;
  animation: fade 0.25s ease both;
}
button.chip:hover {
  background: var(--fill);
}
.chip[data-pressed] {
  background: var(--fill-2);
  transform: scale(0.97);
}
.chipSpinner {
  width: 14px;
  height: 14px;
}
.chipDot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: currentColor;
}
.chipNumber {
  color: var(--ink);
}
.chip[data-chip='conflicts'] {
  color: var(--conflict-ink);
}
.chip[data-chip='conflicts'] .chipDot {
  background: var(--conflict);
}
.chip[data-chip='ready'] {
  color: var(--ready-ink);
}
.chip[data-chip='ready'] .chipDot {
  background: var(--ready);
}
.chip[data-chip='merged'] {
  color: var(--merged-ink);
}
.chipIcon {
  width: 14px;
  height: 14px;
}
.added {
  color: var(--ready-ink);
}
.removed {
  color: var(--removed);
}
.blocks {
  display: flex;
  gap: 1.5px;
  margin-left: 2px;
}
.blocks > span {
  width: 6.5px;
  height: 6.5px;
  border-radius: 1.5px;
  background: var(--fill-2);
}
.blocks > [data-kind='add'] {
  background: var(--ready);
}
.blocks > [data-kind='remove'] {
  background: var(--removed);
}
.chipChevron {
  width: 13px;
  height: 13px;
  margin-left: 2px;
  color: var(--faint);
}
.diffWrap {
  position: relative;
}
.files {
  position: absolute;
  bottom: calc(100% + 6px);
  left: 0;
  z-index: 3;
  min-width: 220px;
  padding: 6px 0;
  border-radius: 10px;
  background: var(--raised);
  box-shadow:
    0 0 0 1px var(--edge),
    0 10px 30px var(--pop-shadow);
  font-size: 13px;
}
.files li {
  display: flex;
  gap: 8px;
  padding: 3px 12px;
}
.fileName {
  flex: 1;
  font-family: var(--mono);
  font-size: 12.5px;
}

/* The composer. */
.composer {
  display: flex;
  flex-direction: column;
  margin: 0;
  padding: 16px 14px 14px 16px;
  border: 1px solid var(--edge);
  border-radius: 16px;
  background: var(--surface);
  box-shadow: 0 2px 8px var(--shadow);
}
.input {
  height: 40px;
  margin: 0 0 14px;
  padding: 0;
  border: 0;
  background: none;
  font-size: 15px;
  line-height: 20px;
  resize: none;
  outline: none;
}
.input::placeholder {
  color: var(--faint);
}
.composer:focus-within {
  border-color: var(--edge-2);
}
.toolbar {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  margin-left: -1px;
}
.round,
.mic {
  display: grid;
  flex: none;
  place-items: center;
  width: 34px;
  height: 34px;
  border-radius: 50%;
  color: var(--ink-2);
}
.round {
  background: var(--fill);
}
.round:hover,
.mic:hover,
.effort:hover {
  background: var(--fill-2);
}
.pill {
  display: flex;
  align-items: center;
  gap: 7px;
  min-width: 0;
  height: 34px;
  padding: 0 14px 0 12px;
  border-radius: 17px;
  background: var(--fill);
  color: var(--ink-2);
}
.pillLabel {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.segments {
  display: flex;
  flex: none;
  padding: 2px;
  border-radius: 17px;
  background: var(--fill);
}
.segment {
  display: flex;
  align-items: center;
  gap: 7px;
  height: 30px;
  padding: 0 10px;
  border-radius: 15px;
  color: var(--ink-2);
  transition:
    background-color 0.15s ease,
    box-shadow 0.15s ease;
}
.segment[aria-checked='true'] {
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--edge),
    0 1px 2px var(--shadow);
  color: var(--ink);
}
.spacer {
  flex: 1;
}
.effort {
  display: flex;
  flex: none;
  align-items: center;
  gap: 8px;
  height: 34px;
  padding: 0 8px;
  border-radius: 17px;
  color: var(--ink-2);
}
.bars {
  display: block;
  width: 16px;
  height: 16px;
}
.bars rect {
  fill: var(--shine);
}
.bars rect[data-on] {
  fill: var(--ink);
}
.send {
  display: grid;
  flex: none;
  place-items: center;
  width: 34px;
  height: 34px;
  border-radius: 50%;
  background: var(--accent);
  color: #fff;
  transition: opacity 0.15s ease;
}
.send[aria-disabled] {
  cursor: default;
}
.send .icon {
  width: 17px;
  height: 17px;
}

/* A spinner: a faint ring and a turning arc. */
.spinner {
  display: block;
  flex: none;
  width: 16px;
  height: 16px;
  animation: spin 0.9s linear infinite;
}
.spinnerTrack {
  fill: none;
  stroke: var(--edge-2);
  stroke-width: 1.6;
}
.spinnerArc {
  fill: none;
  stroke: var(--ink-2);
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-dasharray: 30 70;
}

/* The demo pointer, over everything; one of its glyphs shows at a time. */
.pointerLayer {
  position: absolute;
  inset: 0;
  z-index: 5;
  pointer-events: none;
}
.pointer {
  width: 22px;
  height: 22px;
}
.hand {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .arrow {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .hand {
  visibility: visible;
}

.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* Narrow: the pills, then the hashes and the composer's words, step aside. */
@container (max-width: 600px) {
  .row .branchPill[data-hue='side'] {
    display: none;
  }
  .effortLabel {
    display: none;
  }
}
@container (max-width: 500px) {
  .branchPill {
    display: none;
  }
  .hash {
    display: none;
  }
  .segmentLabel,
  .pillLabel {
    display: none;
  }
  .pill {
    padding: 0 9px;
  }
  .root {
    padding: 20px 10px 16px;
  }
  .card {
    padding: 15px 12px 12px;
  }
  .footer {
    flex-wrap: wrap;
    row-gap: 8px;
  }
  .footerText {
    flex: 1 0 auto;
  }
  .chips {
    flex-wrap: wrap;
  }
}
@container (max-width: 420px) {
  .pill,
  .mic,
  .blocks {
    display: none;
  }
  .toolbar {
    gap: 6px;
  }
}

@keyframes rise {
  from {
    opacity: 0;
    transform: translateY(8px);
  }
}
@keyframes fade {
  from {
    opacity: 0;
  }
}
@keyframes fadeIn {
  from {
    opacity: 0;
    filter: blur(3px);
  }
}
@keyframes grow {
  from {
    height: 0;
    opacity: 0;
  }
}
@keyframes flash {
  from,
  30% {
    background-color: var(--flash, transparent);
  }
  to {
    background-color: transparent;
  }
}
@keyframes sweep {
  0% {
    background-color: transparent;
  }
  25%,
  55% {
    background-color: var(--merged-tint);
  }
  100% {
    background-color: transparent;
  }
}
@keyframes word {
  from {
    opacity: 0;
    filter: blur(4px);
  }
}
@keyframes shimmer {
  from {
    background-position: 100% 0;
  }
  to {
    background-position: 0 0;
  }
}
@keyframes spin {
  to {
    transform: rotate(1turn);
  }
}
@keyframes pop {
  from {
    opacity: 0;
    transform: scale(0.6);
  }
}
@keyframes halo {
  from {
    opacity: 0.35;
    transform: scale(1);
  }
  to {
    opacity: 0;
    transform: scale(1.75);
  }
}
@keyframes draw {
  from {
    stroke-dashoffset: 1;
  }
  to {
    stroke-dashoffset: 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .root *,
  .root *::before,
  .root *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    animation-delay: 0s !important;
    transition-duration: 0.01ms !important;
    transition-delay: 0s !important;
  }
  .shimmer {
    background: none;
    color: var(--ink);
  }
}
```

### merge-graph-card/model.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

/*
 * What the card shows, as plain data. The whole turn is one Model; the agent's work is a script of
 * small patches to it, each after a pause (CREATE, RESOLVE, MERGE). The rows of the commit graph,
 * and the edges between them, are worked out from the Model by `graphOf`: nothing about the graph
 * is stored, so every state draws the same way whether it is reached step by step or at once.
 */

export type Stage =
  'idle' | 'sent' | 'working' | 'conflicts' | 'resolving' | 'ready' | 'merging' | 'merged';

/** The stages a card may start at, already drawn. */
export type StartStage = 'idle' | 'conflicts' | 'ready' | 'merged';

/** The status chip over the composer. */
export type Chip =
  'create' | 'creating' | 'conflicts' | 'resolving' | 'ready' | 'merging' | 'merged';

/** The card's last line: what the agent is doing, or what is left to do, and its button. */
export type Footer = {
  kind: 'busy' | 'conflicts' | 'ready' | 'merged';
  text: string;
  action?: 'resolve' | 'resolving' | 'merge' | 'merging' | 'open';
};

/** The merge of the branch into the base, the graph's top row. */
export type MergeState =
  'none' | 'pending' | 'checking' | 'conflicts' | 'resolved' | 'ready' | 'merging' | 'merged';

/** The merge of the base into the branch, which resolves the conflicts. */
export type MergeIn = 'none' | 'resolving' | 'testing' | 'pushing' | 'done';

export type Model = {
  stage: Stage;
  bubble: boolean;
  /** The disclosure over the card: Thinking while the agent works, then Worked for. */
  thinking: boolean;
  seconds: number;
  card: boolean;
  /** The last changes are committed: the top row is a commit, no longer "Uncommitted changes". */
  committed: boolean;
  /** The pull request is open: its number shows in the title, and its status. */
  opened: boolean;
  merge: MergeState;
  /** The base's new commits are in the graph. */
  fetched: boolean;
  /** Conflicts left on each of the base's two new commits. */
  left: [number, number];
  /** The commit whose conflict is being resolved. */
  busy: 0 | 1 | null;
  /** Once the branch is green, the conflict marks leave the rows. */
  cleared: boolean;
  mergeIn: MergeIn;
  tests: number;
  checks: boolean;
  footer: Footer | null;
  paragraph: boolean;
  chip: Chip;
  /** What the agent did, for the disclosure. */
  log: string[];
};

export const TESTS = 42;

export const INITIAL: Model = {
  stage: 'idle',
  bubble: false,
  thinking: false,
  seconds: 0,
  card: false,
  committed: false,
  opened: false,
  merge: 'none',
  fetched: false,
  left: [1, 2],
  busy: null,
  cleared: false,
  mergeIn: 'none',
  tests: 0,
  checks: false,
  footer: null,
  paragraph: false,
  chip: 'create',
  log: [],
};

const busy = (text: string, action?: Footer['action']): Footer => ({ kind: 'busy', text, action });

/** A step of the script: a pause in ms, then a patch. */
export type Step = [number, Partial<Model>];

/** From "Create PR" to the conflicts the agent can't settle alone. */
export const CREATE: Step[] = [
  [0, { stage: 'sent', bubble: true, chip: 'creating' }],
  [
    900,
    { stage: 'working', thinking: true, card: true, footer: busy('Committing the last changes') },
  ],
  [1300, { committed: true, footer: busy('Pushing 3 commits to origin') }],
  [1300, { merge: 'pending', footer: busy('Opening the pull request') }],
  [500, { opened: true }],
  [500, { footer: busy('Fetching main') }],
  [500, { fetched: true, merge: 'checking', footer: busy('Checking it merges cleanly') }],
  [
    1000,
    {
      stage: 'conflicts',
      thinking: false,
      merge: 'conflicts',
      footer: { kind: 'conflicts', text: 'with main', action: 'resolve' },
      chip: 'conflicts',
    },
  ],
  [400, { paragraph: true }],
];

const running = (tests: number): Step => [
  170,
  { tests, footer: busy(`Running ${tests} of ${TESTS} tests`, 'resolving') },
];

/** "Resolve conflicts": merge the base in, settle each conflict, run the tests, push. */
export const RESOLVE: Step[] = [
  [
    0,
    {
      stage: 'resolving',
      chip: 'resolving',
      footer: busy('Merging main into the branch', 'resolving'),
    },
  ],
  [1000, { mergeIn: 'resolving', busy: 0, footer: busy('Resolving conflict 1 of 3', 'resolving') }],
  [800, { left: [0, 2], busy: 1, footer: busy('Resolving conflict 2 of 3', 'resolving') }],
  [700, { left: [0, 1], footer: busy('Resolving conflict 3 of 3', 'resolving') }],
  [700, { left: [0, 0], busy: null, merge: 'resolved', mergeIn: 'testing', tests: 0 }],
  ...[2, 8, 14, 21, 27, 33, 38, TESTS].map(running),
  [300, { mergeIn: 'pushing', footer: busy('Pushing the merge', 'resolving') }],
  [
    700,
    {
      stage: 'ready',
      merge: 'ready',
      mergeIn: 'done',
      cleared: true,
      checks: true,
      chip: 'ready',
      footer: { kind: 'ready', text: 'Ready to merge', action: 'merge' },
    },
  ],
];

/** "Merge": the branch goes into the base. */
export const MERGE: Step[] = [
  [
    0,
    {
      stage: 'merging',
      merge: 'merging',
      chip: 'merging',
      footer: busy('Merging into main', 'merging'),
    },
  ],
  [
    1000,
    {
      stage: 'merged',
      merge: 'merged',
      checks: false,
      chip: 'merged',
      footer: { kind: 'merged', text: 'Merged into main', action: 'open' },
    },
  ],
];

/** Applies a patch; what the agent starts doing goes into its log. */
export function apply(model: Model, patch: Partial<Model>): Model {
  const next = { ...model, ...patch };
  const text = patch.footer?.kind === 'busy' ? patch.footer.text : null;
  if (text && !text.startsWith('Running')) next.log = [...model.log, text];
  if (patch.mergeIn === 'testing') next.log = [...next.log, `Running ${TESTS} tests`];
  return next;
}

/** A stage drawn at once, as if the script had played up to it. */
export function snapshot(stage: StartStage): Model {
  const steps =
    stage === 'idle'
      ? []
      : stage === 'conflicts'
        ? CREATE
        : stage === 'ready'
          ? [...CREATE, ...RESOLVE]
          : [...CREATE, ...RESOLVE, ...MERGE];
  const model = steps.reduce((model, [, patch]) => apply(model, patch), INITIAL);
  return stage === 'idle' ? model : { ...model, seconds: 5 };
}

/* The graph. */

/** 0 is the base (main), 1 the branch, 2 a colleague's branch, off main too. */
export type Lane = 0 | 1 | 2;

export type Node =
  | 'dot'
  | 'big'
  | 'pending'
  | 'spin'
  | 'ring'
  | 'ring-busy'
  | 'warn'
  | 'check'
  | 'merged'
  | 'conflict'
  | 'conflict-busy';

/** Colour names: tokens of the stylesheet (--base, --branch…). */
export type Hue = 'base' | 'branch' | 'side' | 'conflict' | 'ready' | 'merged';

export type Row = {
  id: string;
  lane: Lane;
  node: Node;
  hue: Hue;
  text: string;
  /** Scrambles in when it first shows, or whenever its text changes. */
  scramble?: boolean;
  /** Its words: the reader's own commits read darker. */
  tone: 'own' | 'other' | 'conflict' | 'quiet' | 'strong';
  pill?: { label: string; hue: 'base' | 'branch' | 'side' };
  badge?: { kind: 'count'; count: number } | { kind: 'check' };
  hash?: string;
  who?: string;
  when?: string;
  /** In place of the hash and author: a word on its state. */
  status?: { text: string; hue: 'quiet' | 'ink' | 'conflict' | 'resolved' | 'ready' | 'merged' };
  /** Comes in by growing open (rows that arrive later), or by fading in with the card. */
  enter: 'fade' | 'grow';
  /** A tint that marks it: a row that just arrived, or one the merge brought in. */
  flash?: 'warm' | 'cool' | 'merged';
  /** The tests' progress, as a share, drawn as a line under the row. */
  progress?: number;
};

export type Edge = {
  id: string;
  from: string;
  /** The row it reaches, below; or 'tail', the base going on past the last row. */
  to: string;
  hue: Hue;
  /** Where it changes lanes: next to the newer commit ('start') or the older one ('end'), or
   * straight across ('straight'). */
  bend?: 'start' | 'end' | 'straight';
  dashed?: boolean;
  /** Draws itself in. */
  draw?: boolean;
};

export type Names = { branch: string; base: string; side: string };

/** The rows and the edges between them, newest first. */
export function graphOf(model: Model, names: Names): { rows: Row[]; edges: Edge[] } {
  const { merge, mergeIn, fetched, left, busy, cleared } = model;
  const merged = merge === 'merged';
  const branchHue: Hue = merged ? 'merged' : 'branch';
  const rows: Row[] = [];
  const edges: Edge[] = [];
  const branchPill = { label: names.branch, hue: 'branch' as const };
  const basePill = { label: names.base, hue: 'base' as const };

  if (merge !== 'none') {
    const node: Node =
      merge === 'pending' || merge === 'checking'
        ? 'pending'
        : merge === 'conflicts' || merge === 'resolved'
          ? 'warn'
          : merge === 'merged'
            ? 'merged'
            : 'check';
    const conflicts = left[0] + left[1];
    const status: Row['status'] =
      merge === 'checking'
        ? { text: 'checking', hue: 'quiet' }
        : merge === 'conflicts'
          ? {
              text: `${conflicts} conflict${conflicts === 1 ? '' : 's'}`,
              hue: conflicts ? 'conflict' : 'resolved',
            }
          : merge === 'resolved'
            ? { text: 'resolved', hue: 'resolved' }
            : merge === 'ready' || merge === 'merging'
              ? { text: 'ready', hue: 'ready' }
              : merge === 'merged'
                ? { text: 'merged', hue: 'merged' }
                : undefined;
    rows.push({
      id: 'merge',
      lane: 0,
      node,
      hue: merged ? 'merged' : 'base',
      text: merged ? `Merged into ${names.base}` : `Merge into ${names.base}`,
      scramble: true,
      tone: merge === 'pending' || merge === 'checking' ? 'strong' : 'quiet',
      pill: merged ? basePill : undefined,
      status,
      enter: 'grow',
      flash: merge === 'pending' ? 'warm' : undefined,
    });
  }

  if (mergeIn !== 'none') {
    const resolving = mergeIn === 'resolving';
    const done = 3 - left[0] - left[1];
    rows.push({
      id: 'merge-in',
      lane: 1,
      node: merged ? 'big' : resolving || mergeIn === 'testing' ? 'ring-busy' : 'ring',
      hue: branchHue,
      text: `Merge ${names.base} into ${names.branch}`,
      scramble: true,
      tone: 'own',
      pill: branchPill,
      ...(resolving
        ? { status: { text: `${Math.min(done + 1, 3)}/3`, hue: 'ink' as const } }
        : mergeIn === 'testing'
          ? { status: { text: `${model.tests}/${TESTS}`, hue: 'ink' as const } }
          : { hash: '7e0c3b', who: 'You', when: 'now' }),
      enter: 'grow',
      flash: merged ? 'merged' : 'cool',
      progress: mergeIn === 'testing' ? model.tests / TESTS : undefined,
    });
  }

  rows.push(
    model.committed
      ? {
          id: 'tests',
          lane: 1,
          node: 'dot',
          hue: branchHue,
          text: 'Add session refresh tests',
          scramble: true,
          tone: 'own',
          pill: mergeIn === 'none' ? branchPill : undefined,
          hash: 'd29f50',
          who: 'You',
          when: 'now',
          enter: 'fade',
          flash: merged ? 'merged' : undefined,
        }
      : {
          id: 'tests',
          lane: 1,
          node: 'spin',
          hue: 'branch',
          text: 'Uncommitted changes',
          tone: 'own',
          pill: branchPill,
          status: { text: '4 files', hue: 'quiet' },
          enter: 'fade',
        },
  );

  if (fetched) {
    const marked = merge !== 'checking' && !cleared;
    ([0, 1] as const).forEach((index) => {
      const conflict = marked && left[index] > 0;
      rows.push({
        id: index ? 'rotate' : 'read',
        lane: 0,
        node: conflict ? (busy === index ? 'conflict-busy' : 'conflict') : 'dot',
        hue: conflict ? 'conflict' : 'base',
        text: index ? 'Rotate session keys' : 'Read sessions with the current key',
        scramble: true,
        tone: conflict ? 'conflict' : 'other',
        pill: index === 0 && !merged ? basePill : undefined,
        badge: !marked
          ? undefined
          : conflict
            ? { kind: 'count', count: left[index] }
            : { kind: 'check' },
        hash: index ? 'a3f9c1' : '5be27d',
        who: 'Lena',
        when: index ? '18m' : '6m',
        enter: 'grow',
        flash: 'warm',
      });
    });
  }

  const own = (id: string, text: string, hash: string, when: string): Row => ({
    id,
    lane: 1,
    node: 'dot',
    hue: branchHue,
    text,
    tone: 'own',
    hash,
    who: 'You',
    when,
    enter: 'fade',
    flash: merged ? 'merged' : undefined,
  });
  const side = (id: string, text: string, hash: string, when: string, pill: boolean): Row => ({
    id,
    lane: 2,
    node: 'dot',
    hue: 'side',
    text,
    tone: 'other',
    pill: pill ? { label: names.side, hue: 'side' } : undefined,
    hash,
    who: 'Sam',
    when,
    enter: 'fade',
  });
  rows.push(
    own('refresh', 'Refresh sessions near expiry', 'c70a92', '25m'),
    side('consent', 'Add analytics consent banner', 'e83c51', '32m', true),
    own('expiry', 'Read session expiry from the token', '1f8e3b', '40m'),
    side('funnel', 'Track checkout funnel events', '62b0e4', '2h', false),
    {
      id: 'bump',
      lane: 0,
      node: 'dot',
      hue: 'base',
      text: 'Bump runtime to 16.3',
      tone: 'other',
      pill: fetched ? undefined : basePill,
      hash: '9c2d4a',
      who: 'Sam',
      when: '3h',
      enter: 'fade',
    },
  );

  const head = mergeIn !== 'none' ? 'merge-in' : 'tests';
  const baseHead = fetched ? 'read' : 'bump';
  if (mergeIn !== 'none') {
    edges.push({ id: 'in-tests', from: 'merge-in', to: 'tests', hue: branchHue });
    edges.push({ id: 'in-base', from: 'merge-in', to: 'read', hue: 'base', bend: 'start' });
  }
  edges.push(
    { id: 'tests-refresh', from: 'tests', to: 'refresh', hue: branchHue },
    { id: 'refresh-expiry', from: 'refresh', to: 'expiry', hue: branchHue },
    { id: 'expiry-bump', from: 'expiry', to: 'bump', hue: branchHue, bend: 'end' },
    { id: 'consent-funnel', from: 'consent', to: 'funnel', hue: 'side' },
    { id: 'funnel-bump', from: 'funnel', to: 'bump', hue: 'side', bend: 'end' },
    { id: 'tail', from: 'bump', to: 'tail', hue: 'base', dashed: true },
  );
  if (fetched) {
    edges.push(
      { id: 'read-rotate', from: 'read', to: 'rotate', hue: 'base' },
      { id: 'rotate-bump', from: 'rotate', to: 'bump', hue: 'base' },
    );
  }
  if (merge !== 'none') {
    const linkHue: Hue =
      merge === 'conflicts' || merge === 'resolved'
        ? 'conflict'
        : merge === 'ready'
          ? 'ready'
          : merge === 'merging' || merged
            ? 'merged'
            : 'branch';
    const solid = merge === 'merging' || merged;
    edges.push(
      // Keyed by what it joins, so it draws itself in again when the merge commit arrives.
      {
        id: `link-${head}`,
        from: 'merge',
        to: head,
        hue: linkHue,
        bend: 'straight',
        dashed: !solid,
        draw: solid,
      },
      { id: 'merge-base', from: 'merge', to: baseHead, hue: 'base', dashed: !merged },
    );
  }
  return { rows, edges };
}
```

### merge-graph-card/graph.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

import type { Edge, Lane, Node, Row } from './model';
import styles from './merge-graph-card.module.css';

/*
 * The commit graph, drawn row by row. Every edge is one path in the coordinates of the whole list
 * (row k's middle is at k × PITCH + PITCH / 2), and each row has an SVG of its own whose viewBox is
 * its band of that list: it shows the pieces of every edge that cross it, and its own node. So a
 * row that arrives later opens up between two others with the lanes running through it, and the
 * rows never need one drawing laid over them all.
 */

/** A row's height, in px. */
export const PITCH = 30;
/** The graph's width, and where its three lanes run. */
export const GRAPH = 61;
const LANES = [9.5, 26.5, 43.5];

const x = (lane: Lane) => LANES[lane];
const y = (index: number) => index * PITCH + PITCH / 2;

/** One edge, as a path in the list's coordinates. */
function pathOf(edge: Edge, rows: Row[], index: Map<string, number>): string | null {
  const from = index.get(edge.from);
  if (from === undefined) return null;
  const x1 = x(rows[from].lane);
  const y1 = y(from);
  if (edge.to === 'tail') return `M${x1} ${y1 + 6}V${y1 + PITCH - 2}`;
  const to = index.get(edge.to);
  if (to === undefined) return null;
  const x2 = x(rows[to].lane);
  const y2 = y(to);
  if (x1 === x2) return `M${x1} ${y1}V${y2}`;
  if (edge.bend === 'straight') return `M${x1} ${y1}L${x2} ${y2}`;
  // It changes lanes over at most two rows, next to one end, and runs straight the rest.
  const span = Math.min(y2 - y1, PITCH * 2);
  const h = span / 2;
  if (edge.bend === 'start') {
    const y3 = y1 + span;
    return `M${x1} ${y1}C${x1} ${y1 + h} ${x2} ${y3 - h} ${x2} ${y3}V${y2}`;
  }
  const y0 = y2 - span;
  return `M${x1} ${y1}V${y0}C${x1} ${y0 + h} ${x2} ${y2 - h} ${x2} ${y2}`;
}

export type Paths = { id: string; d: string; edge: Edge }[];

export function pathsOf(rows: Row[], edges: Edge[]): Paths {
  const index = new Map(rows.map((row, i) => [row.id, i]));
  return edges.flatMap((edge) => {
    const d = pathOf(edge, rows, index);
    return d ? [{ id: edge.id, d, edge }] : [];
  });
}

/** A row's node: a dot, or one of the marks the merge goes through. */
function NodeMark({ node, cx, cy }: { node: Node; cx: number; cy: number }) {
  const at = { cx, cy };
  switch (node) {
    case 'dot':
      return <circle className={styles.dot} {...at} r={3.6} />;
    case 'big':
      return <circle className={styles.dot} {...at} r={4.6} />;
    case 'pending':
      return <circle className={styles.pending} {...at} r={5.5} pathLength={24} />;
    case 'spin':
      return (
        <g className={styles.spinNode}>
          <circle className={styles.dot} {...at} r={3.4} />
          <circle className={styles.track} {...at} r={6.5} />
          <circle className={styles.arc} {...at} r={6.5} pathLength={100} />
        </g>
      );
    case 'ring':
      return <circle className={styles.ring} {...at} r={4.2} />;
    case 'ring-busy':
      return (
        <g className={styles.spinNode}>
          <circle className={styles.ring} {...at} r={4.2} />
          <circle className={styles.arc} {...at} r={7} pathLength={100} />
        </g>
      );
    case 'conflict':
      return <circle className={styles.dot} {...at} r={3.6} />;
    case 'conflict-busy':
      return (
        <g className={styles.spinNode}>
          <circle className={styles.dot} {...at} r={3.6} />
          <circle className={styles.arc} {...at} r={6} pathLength={100} />
        </g>
      );
    case 'warn':
      return (
        <g className={styles.badgeNode}>
          <circle className={styles.halo} {...at} r={6.5} />
          <circle className={styles.disc} {...at} r={6.5} />
          <path className={styles.glyph} d={`M${cx} ${cy - 3.2}v3.6`} />
          <circle className={styles.glyphDot} cx={cx} cy={cy + 2.7} r={0.95} />
        </g>
      );
    case 'check':
      return (
        <g className={styles.badgeNode}>
          <circle className={styles.halo} {...at} r={6.5} />
          <circle className={styles.disc} {...at} r={6.5} />
          <path
            className={styles.glyph}
            d={`M${cx - 2.8} ${cy + 0.2}l1.9 1.9 3.7-4`}
            pathLength={1}
          />
        </g>
      );
    case 'merged':
      return (
        <g className={styles.badgeNode}>
          <circle className={styles.halo} {...at} r={7} />
          <circle className={styles.disc} {...at} r={7} />
        </g>
      );
  }
}

/** One row's band of the graph. */
export function Slice({ row, index, paths }: { row: Row; index: number; paths: Paths }) {
  const top = index * PITCH;
  return (
    <svg
      className={styles.graph}
      viewBox={`0 ${top} ${GRAPH} ${PITCH}`}
      data-part="graph"
      aria-hidden="true"
      focusable="false"
    >
      {paths.map(({ id, d, edge }) => (
        <path
          key={id}
          className={styles.edge}
          d={d}
          data-hue={edge.hue}
          data-dashed={edge.dashed || undefined}
          data-draw={edge.draw || undefined}
          pathLength={edge.draw ? 1 : undefined}
        />
      ))}
      <g className={styles.node} data-node={row.node} data-hue={row.hue}>
        <NodeMark key={row.node} node={row.node} cx={x(row.lane)} cy={y(index)} />
      </g>
    </svg>
  );
}
```

### merge-graph-card/scramble.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import { useLayoutEffect, useRef } from 'react';
import styles from './merge-graph-card.module.css';

/*
 * Words that decode into place, the way the agent's new rows arrive: the line grows from a few
 * random letters to its full length while the real text takes over from the left. It writes the
 * span's text straight from a frame loop (no React render per frame). Screen readers get the real
 * words from a hidden copy; the moving one is aria-hidden. Under reduced motion it never runs.
 */

const LETTERS = 'abcdefghijklmnopqrstuvwxyz0123456789';
/** How often the random letters change, in ms. */
const FLICKER = 45;

const noise = (length: number, from: string) =>
  Array.from({ length }, (_, i) =>
    from[i] === ' ' ? ' ' : LETTERS[Math.floor(Math.random() * LETTERS.length)],
  ).join('');

export function Scramble({
  text,
  active = true,
  className,
}: {
  text: string;
  /** Plays when it first shows and whenever the text changes; off, it just shows the text. */
  active?: boolean;
  className?: string;
}) {
  const span = useRef<HTMLSpanElement>(null);
  const shown = useRef<string | null>(null);

  useLayoutEffect(() => {
    const element = span.current;
    if (!element) return;
    const first = shown.current === null;
    shown.current = text;
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    if (!active || reduced || (!first && element.textContent === text)) {
      element.textContent = text;
      return;
    }
    const duration = Math.min(820, Math.max(420, text.length * 30));
    const start = performance.now();
    let frame = 0;
    let last = -FLICKER;
    let tail = '';
    const tick = (now: number) => {
      const t = Math.min(1, (now - start) / duration);
      if (t >= 1) {
        element.textContent = text;
        return;
      }
      // The line grows to its length in the first two thirds; the text takes over behind it.
      const length = Math.ceil(text.length * Math.min(1, 0.4 + t * 0.9));
      const real = Math.floor(text.length * t * t);
      if (now - last >= FLICKER) {
        last = now;
        tail = noise(text.length, text);
      }
      element.textContent = text.slice(0, real) + tail.slice(real, length);
      frame = requestAnimationFrame(tick);
    };
    tick(start);
    return () => cancelAnimationFrame(frame);
  }, [text, active]);

  return (
    <span className={`${styles.scramble} ${className ?? ''}`}>
      <span className={styles.hidden}>{text}</span>
      <span ref={span} className={styles.scrambleText} aria-hidden="true">
        {text}
      </span>
    </span>
  );
}
```

### merge-graph-card/icons.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

import type { ReactNode } from 'react';
import styles from './merge-graph-card.module.css';

/*
 * The card's icons, drawn on a 16-unit grid with round strokes in currentColor. They are
 * decorative: every control that shows one is named in words. The stylesheet sizes them.
 */

const props = {
  viewBox: '0 0 16 16',
  'aria-hidden': true,
  focusable: false,
  fill: 'none',
  stroke: 'currentColor',
  strokeWidth: 1.4,
  strokeLinecap: 'round',
  strokeLinejoin: 'round',
} as const;

export type IconName =
  | 'chevron-right'
  | 'chevron-down'
  | 'plus'
  | 'folder'
  | 'laptop'
  | 'cloud'
  | 'mic'
  | 'arrow-up'
  | 'warning'
  | 'check'
  | 'merge'
  | 'open';

const PATHS: Record<IconName, ReactNode> = {
  'chevron-right': <path d="M6.5 4l4 4-4 4" />,
  'chevron-down': <path d="M4 6.5l4 4 4-4" />,
  plus: <path d="M8 3v10M3 8h10" />,
  folder: (
    <path d="M1.8 4.2c0-.8.6-1.4 1.4-1.4h2.6l1.5 1.6h5.4c.8 0 1.4.6 1.4 1.4v6c0 .8-.6 1.4-1.4 1.4H3.2c-.8 0-1.4-.6-1.4-1.4zM1.8 6.6h12.4" />
  ),
  laptop: <path d="M3.2 4.2c0-.5.4-.9.9-.9h7.8c.5 0 .9.4.9.9v6H3.2zM1.4 12.4h13.2" />,
  cloud: <path d="M4.6 12.2h6.9a2.9 2.9 0 00.4-5.8 4 4 0 00-7.7.9 2.5 2.5 0 00.4 4.9z" />,
  mic: (
    <>
      <rect x="5.8" y="1.8" width="4.4" height="7.6" rx="2.2" />
      <path d="M3.6 7.6a4.4 4.4 0 008.8 0M8 12v2.2" />
    </>
  ),
  'arrow-up': <path d="M8 13V3.4M3.8 7.4L8 3.2l4.2 4.2" strokeWidth={1.7} />,
  warning: (
    <>
      <path d="M7.1 2.6a1 1 0 011.8 0l5.4 9.6a1 1 0 01-.9 1.5H2.6a1 1 0 01-.9-1.5z" />
      <path d="M8 6.2v3" />
      <circle cx="8" cy="11.3" r="0.4" fill="currentColor" />
    </>
  ),
  check: <path d="M3.4 8.4l2.9 2.9 6.3-6.6" />,
  merge: (
    <>
      <circle cx="4.2" cy="3.4" r="1.6" />
      <circle cx="4.2" cy="12.6" r="1.6" />
      <circle cx="11.8" cy="8.6" r="1.6" />
      <path d="M4.2 5v6M4.2 5c0 2.4 2.1 3.6 5.9 3.6" />
    </>
  ),
  open: (
    <>
      <path d="M6.6 3H4a1.4 1.4 0 00-1.4 1.4V12A1.4 1.4 0 004 13.4h7.6A1.4 1.4 0 0013 12V9.4" />
      <path d="M9.4 2.6h4v4M13.2 2.8L7.6 8.4" />
    </>
  ),
};

export function Icon({ name, className }: { name: IconName; className?: string }) {
  return (
    <svg {...props} className={`${styles.icon} ${className ?? ''}`} data-icon={name}>
      {PATHS[name]}
    </svg>
  );
}

/** A small ring with a gap that turns, for work in progress. */
export function Spinner({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 16 16"
      aria-hidden="true"
      focusable="false"
      className={`${styles.spinner} ${className ?? ''}`}
    >
      <circle cx="8" cy="8" r="6" className={styles.spinnerTrack} />
      <circle cx="8" cy="8" r="6" className={styles.spinnerArc} pathLength={100} />
    </svg>
  );
}

/** The effort meter: four rising bars, the first `level` of them dark. */
export function Bars({ level }: { level: number }) {
  return (
    <svg viewBox="0 0 16 16" aria-hidden="true" focusable="false" className={styles.bars}>
      {[0, 1, 2, 3].map((bar) => (
        <rect
          key={bar}
          x={1.5 + bar * 3.6}
          y={12.5 - (bar + 1) * 2.5}
          width={2.2}
          height={(bar + 1) * 2.5 + 1}
          rx={0.6}
          data-on={bar < level || undefined}
        />
      ))}
    </svg>
  );
}
```

### merge-graph-card/demo-pointer.tsx

```tsx
'use client';

import type { CSSProperties, Ref } from 'react';

/*
 * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or
 * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends
 * the step in progress at once: its promise rejects, which ends the script right there, and the
 * pointer fades out.
 *
 * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame.
 * The points of the flight are handed to whoever wants to move along with it. The arrow and the
 * hand are HTML boxes that swap by their visibility: a change of style inside an SVG would lay
 * the SVG out again.
 */

export type Point = { x: number; y: number };

/** A glide's path and timing, for an animation that plays along with it. */
export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer to `to`, in the stage's own px, in `ms`; `along` gets the flight. */
  glide: (to: Point, ms: number, along?: (flight: Flight) => void) => Promise<void>;
  /** Shows the arrow, or the hand a link shows. */
  glyph: (name: 'arrow' | 'hand') => void;
};

/** What a stopped demo's pending step rejects with. */
class Stopped extends Error {}

/** Where the arrow's tip is, as shares of its box: the point the pointer stands on. */
const TIP: Point = { x: 0.15, y: 0.1 };

/** How many points a flight is drawn through. */
const SAMPLES = 24;

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: HTMLElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  // The stylesheet sizes the pointer from the stage's width; its tip is where it stands.
  const tip = { x: pointer.offsetWidth * TIP.x, y: pointer.offsetHeight * TIP.y };
  const place = ({ x, y }: Point) => `translate(${x - tip.x}px, ${y - tip.y}px)`;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.8, y: stage.offsetHeight * 0.9 };

  /** Settles like `promise`, or rejects as soon as the demo stops. */
  const step = (promise: Promise<unknown>) =>
    new Promise<void>((resolve, reject) => {
      if (!alive) return reject(new Stopped());
      const cancel = () => reject(new Stopped());
      pending.add(cancel);
      promise.then(
        () => {
          pending.delete(cancel);
          if (alive) resolve();
        },
        () => {
          pending.delete(cancel);
          reject(new Stopped());
        },
      );
    });

  const wait = (ms: number) =>
    step(
      new Promise<void>((resolve) => {
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          resolve();
        }, ms);
        timers.add(timer);
      }),
    );

  const glide: Demo['glide'] = (to, ms, along) => {
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.18, y: (from.y + to.y) / 2 + dx * 0.18 };
    const points: Point[] = [];
    for (let i = 0; i <= SAMPLES; i += 1) {
      const t = i / SAMPLES;
      const u = 1 - t;
      points.push({
        x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
        y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
      });
    }
    const timing: KeyframeAnimationOptions = {
      duration: ms,
      easing: 'cubic-bezier(0.45, 0, 0.25, 1)',
    };
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(
      points.map((point) => ({ transform: place(point) })),
      timing,
    );
    along?.({ points, timing });
    return step(flight.finished);
  };

  const glyph: Demo['glyph'] = (name) => {
    pointer.dataset.glyph = name;
  };

  pointer.dataset.glyph = 'arrow';
  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, glide, glyph }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

  return () => {
    if (!alive) return;
    alive = false;
    timers.forEach((timer) => window.clearTimeout(timer));
    flight?.cancel();
    pending.forEach((cancel) => cancel());
    pointer.style.opacity = '0';
  };
}

/**
 * The pointer's box: it moves, on a layer of its own. Its size comes from its class, its colours
 * from the stage's tokens (merge-graph-card.module.css).
 */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 2,
  display: 'block',
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
  willChange: 'transform',
};

/** One glyph over the other, each filling the box. */
const GLYPH: CSSProperties = { position: 'absolute', inset: 0, display: 'block' };
const DRAWING: CSSProperties = {
  display: 'block',
  width: '100%',
  height: '100%',
  overflow: 'visible',
};
/** The arrow is dark on a white edge, the hand white on a dark one, as the system draws them. */
const ARROW: CSSProperties = { fill: 'var(--pointer)', stroke: 'var(--pointer-edge)' };
const HAND: CSSProperties = { fill: 'var(--pointer-edge)', stroke: 'var(--pointer)' };

/**
 * The pointer the demo moves around: an arrow, or a pointing hand over a button. It is
 * decorative: what it does shows on the card itself. The stylesheet shows one glyph at a time,
 * from the box's data-glyph.
 */
export function DemoPointer({
  ref,
  className,
  arrowClass,
  handClass,
}: {
  ref: Ref<HTMLSpanElement>;
  className?: string;
  arrowClass?: string;
  handClass?: string;
}) {
  return (
    <span ref={ref} className={className} style={POINTER} aria-hidden="true">
      <span className={arrowClass} style={GLYPH}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          <path
            d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
            style={ARROW}
            strokeWidth="1.3"
            strokeLinejoin="round"
          />
        </svg>
      </span>
      <span className={handClass} style={GLYPH}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          {/* The tip of the finger sits where the arrow's tip is. */}
          <path
            d="M6.4 3.3c0-.8.6-1.4 1.3-1.4S9 2.5 9 3.3v5.5c.2-.5.6-.8 1.2-.8.7 0 1.2.5 1.2 1.2v.5c.2-.5.6-.8 1.2-.8.7 0 1.2.5 1.2 1.2v.7c.2-.4.6-.7 1.1-.7.7 0 1.2.5 1.2 1.2v3.5c0 2.8-1.9 4.9-4.6 4.9h-1.2c-1.8 0-3.1-.8-4.1-2.3l-2.6-3.9c-.4-.6-.2-1.3.4-1.7.6-.3 1.3-.2 1.7.4l.6.9z"
            style={HAND}
            strokeWidth="1.1"
            strokeLinejoin="round"
            transform="translate(-4.7 0.1)"
          />
        </svg>
      </span>
    </span>
  );
}
```

### merge-graph-card/use-autoplay.ts

```tsx
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, visible, reduced, playing: visible && !reduced && !taken };
}
```