# Redline review

> An AI contract review card: steps tick off, a market swarm places their draft and the redline, and the next redline drafts below.

An agent reads a contract, checks it against a playbook and finds three clauses off. The liability cap shows where it stands in the market: a swarm of past contracts, their draft far down the left, the playbook minimum as a dashed line and the redline lit on it, with percentiles counted from the data. Accept or reject it, watch the next redline draft, and give the drafter your own instruction.

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

Category: AI.

## Anatomy

1. **Header**: The contract’s name over its version, page count and playbook, beside a document badge.
2. **Tabs**: Review lists the clauses that are off; Redlines shows the one on the chart. Its count is the redlines still waiting.
3. **Steps**: What the agent did, with a spinner while it runs and a green tick when done: pages read, clauses ok and off.
4. **Market swarm**: One dot for two past contracts on a months axis, grey below the playbook minimum and violet from it. The red ring is their draft, the lit dot the redline, an arc between.
5. **Legend**: Each marker with its months and its percentile, worked out from the same market.
6. **Decision**: A note on what the redline changes, with Reject and Accept. Once settled, the losing side dims and Undo replaces both.
7. **Drafting line**: The next redline being drafted, its count, and Cancel. A violet-to-red line under it stretches every 450 ms.
8. **Composer**: An instruction for the drafter: + cites a clause, Redline or Compare sets the mode, Enter or the arrow sends.

## When to use

- An agent reviewing a contract against a playbook, where the reader has to settle each change it proposes.
- A number that only means something next to others like it: a cap, a notice period, a fee, placed among past deals.
- Showing the work before the verdict: what the agent read and checked, with counts, ahead of the clause it flags.
- A review that keeps going while the reader decides: the next redline drafts below, and they can steer it in words.

## When not to use

- The whole contract with every change marked in place. Use a document view with tracked changes.
- Many flagged clauses to sort and assign. Use a table of clauses with filters and bulk actions.
- A value with no market behind it, or fewer than a few dozen comparable deals: the swarm reads as noise. Show the value against the playbook alone.
- A plain chat with the drafter. Use a chat thread; this card is built around one clause at a time.
- Uploading the contract. Use a dropzone first; this card starts once the file is read.

## Installation

Install it with the shadcn CLI, into components/redline-review of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/redline-review.json
```

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 { RedlineReview } from './redline-review';

export function ContractReview() {
  return (
    <RedlineReview
      title="Larkspur MSA"
      draftMonths={3}
      redlineMonths={12}
      onDecision={(decision) => console.log(decision)}
      onSend={(prompt, mode) => console.log(mode, prompt)}
    />
  );
}
```

## States

- **Reading**: The first step spins while the page count runs up; the chart waits, hidden.
- **Checking**: Clauses tick past, `ok` in green and `off` in red, until all 18 are checked.
- **Redline open**: Both steps done and the chart drawn at rest: their draft at p4, the redline at p49.
- **Column hovered**: A mouse or pen over the swarm bands the nearest column and gives its months and count.
- **Accepted**: The draft ring dims, a tick replaces the sparkle, the tab count drops to 2, Undo appears.
- **Rejected**: The redline and its arc dim; the note says their draft stands.
- **Review tab**: The three clauses that are off, each with where its redline is. Only the first opens the chart.
- **Drafting stopped**: Cancel freezes the line and turns into Resume.
- **All drafted**: The line is full, a tick replaces the spinner, and Redraft starts again.
- **Compare, empty**: Compare mode with nothing typed: its own placeholder, and Send disabled.
- **Light**: Every colour is a light-dark() token: `colorScheme="light"`, or a light page.
- **Dark**: `colorScheme="dark"`, or a dark page.

## How it works

- The market is one SVG of 729 dots in columns along a months axis, one dot for two contracts: grey below the playbook minimum, violet from it, each fainter the farther it stacks from the axis. It scales with the card, while the labels around it are HTML and keep their size.
- The percentiles are worked out from the same data: the share of contracts with a lower cap, half of those with the same one. Their draft at 3 months lands at p4, the redline at 12 at p49.
- When the chart plays, every dot falls into place on a CSS animation of its own, delayed by its column and its distance from the axis. The playbook line then draws down, the draft ring pops, the arc draws itself through a mask whose dash offset runs from 1 to 0, and the redline dot pops at its end while the percentiles count up. A hidden copy of each final number holds its place, so the legend never shifts.
- Accept or Reject settles the redline: the losing side dims, the note says what stands, and Undo opens it again. The tab counts the redlines still waiting.
- The drafting line moves on by itself while on screen: a violet-to-red glow under the row stretches by scaleX, and the count moves to the next clause. Cancel stops it, and Resume picks it up again.
- While on screen and untouched, a demo plays the review from the start: the page and clause counts tick up, the chart plays, an instruction is typed, the redline is accepted and the instruction sent. Any real input hands the card to the reader.
- Every colour is a light-dark() token, so the card follows the page or the colorScheme prop. Off screen, in a hidden tab or under reduced motion nothing moves, and the card is drawn at rest.

## API reference

- **title** (`string`, default `'Larkspur MSA'`): The contract’s name, over its version, length and playbook.
- **file** (`string`, default `'Larkspur_MSA_v4.pdf'`): The file the agent read, in the first step.
- **playbook** (`string`, default `'SaaS vendor v3'`): The playbook the clauses were checked against.
- **draftMonths** (`number`, default `3`): Their draft’s liability cap in months (0 to 25): where the red ring sits.
- **redlineMonths** (`number`, default `12`): The redline’s cap in months: where the lit dot sits, and where the arc ends.
- **playbookMonths** (`number`, default `12`): The playbook minimum in months: the dashed line, with grey contracts below it.
- **defaultPrompt** (`string`): What the composer holds at first.
- **onDecision** (`(decision: 'open' | 'accepted' | 'rejected') => void`): Called when the redline is accepted, rejected or opened again with Undo.
- **onSend** (`(prompt: string, mode: 'redline' | 'compare') => void`): Called with the composer’s text and mode when it is sent (the button or Enter).
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo plays the review from the start, over and over. Any real input hands control to the reader for six seconds.
- **defaultState** (`{ steps?: 1 | 2 | 3; tab?: 'review' | 'redlines'; decision?: 'open' | 'accepted' | 'rejected'; mode?: 'redline' | 'compare'; drafting?: 'running' | 'stopped' | 'done'; hoverMonths?: number }`): Opens in this state, with the demo off: the steps still reading (1) or checking (2), a tab, a settled decision, the composer’s mode, the drafting line stopped or done, or the chart’s tooltip on the column nearest hoverMonths.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark colours, or the reader’s; without it, the page’s color-scheme.
- **className** (`string`): Added to the stage.

## Accessibility

- The card is an article named by the contract; the steps are an ordered list of real text, and their icons are hidden from assistive technology.
- Review and Redlines are tabs: arrow keys, Home and End move between them, and each controls the panel below.
- The chart is an image with a full description: how many contracts it shows, and where their draft, the playbook minimum and the redline stand. The hover tooltip only repeats a column’s count for a mouse or pen.
- Accept, Reject, Undo, Cancel and Send are real buttons, and Redline and Compare a radio group. The drafting line is a progressbar, and decisions, sending and drafting announce themselves in a polite live region.
- Every control shows a focus ring. Under reduced motion nothing animates or moves on its own, and the demo stays off.

## Source

### redline-review/redline-review.tsx

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

'use client';

import {
  memo,
  useEffect,
  useEffectEvent,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type FormEvent,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import {
  AXIS_Y,
  COLUMNS,
  DOT_R,
  DOTS,
  PER_DOT,
  PITCH,
  PX_PER_MONTH,
  VIEW_H,
  VIEW_W,
  arcPath,
  percentile,
  type Column,
} from './market';
import { useAutoplay } from './use-autoplay';
import styles from './redline-review.module.css';

/*
 * Redline review: an agent reviewing a contract against a playbook, in one dark card. Two steps
 * tick off (it read the file, it checked every clause), then the clause that is off shows where
 * it stands in the market: a swarm of past contracts on a months axis, their draft as a red ring
 * far down the left, the playbook's minimum as a dashed line, and the redline as a lit dot on it,
 * with an arc from one to the other. Below, the next redline is being drafted, and a composer
 * takes the reader's own instructions.
 *
 * - The swarm is one SVG of about 700 dots in columns, grey below the playbook's minimum and
 *   violet from it, each fainter the farther it stacks from the axis. It scales with the card; the
 *   labels around it are HTML, so they keep their size.
 * - When the chart plays, every dot falls into place on a CSS animation of its own, delayed by its
 *   column and its distance from the axis; then the playbook line draws down, the draft's ring
 *   pops, the arc draws itself (a solid copy of it in a mask, its dash offset running from 1 to 0)
 *   and the redline's dot pops at its end, while the percentiles count up.
 * - The percentiles are worked out from the market itself: the share of contracts with a lower
 *   cap, half of those with the same one.
 * - Accept or Reject settles the redline: the side that lost dims, the note says what stands, and
 *   Undo opens it again. The tab's count is the redlines still waiting.
 * - The drafting line moves on by itself while it is on screen: a progress line under its row, in
 *   a violet-to-red glow that stretches by a transform, and a count of the redlines.
 * - While on screen and untouched, a demo plays the review from the start: the steps tick in, the
 *   chart plays, an instruction is typed into the composer, the redline is accepted and the
 *   instruction sent; 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.
 */

export type RedlineDecision = 'open' | 'accepted' | 'rejected';
export type ComposerMode = 'redline' | 'compare';

/** A state to open in, for docs and tests; any of it turns the demo off. */
export type RedlineReviewState = {
  /** How far the steps are: 1 reading the file, 2 checking the clauses, 3 both done (default). */
  steps?: 1 | 2 | 3;
  tab?: 'review' | 'redlines';
  decision?: RedlineDecision;
  mode?: ComposerMode;
  /** The drafting line: moving, stopped with Cancel, or done with every redline. */
  drafting?: 'running' | 'stopped' | 'done';
  /** The chart's hover tooltip, on the column nearest these months. */
  hoverMonths?: number;
};

/** The redlines being drafted, in order; the first is the one on the chart. */
const QUEUE = ['§ 9.2 Liability cap', '§ 12.4 Indemnity', '§ 14.1 Renewal'];
/** The clauses the check found off, for the Review tab. */
const FLAGGED = [
  { clause: '§ 9.2', title: 'Liability cap', detail: '3 mo against a 12 mo minimum' },
  { clause: '§ 12.4', title: 'Indemnity', detail: 'Carve-out runs one way only' },
  { clause: '§ 14.1', title: 'Renewal', detail: '90-day notice against 30' },
];
const CLAUSES = 18;
/** Where in the check each clause that is off turns up. */
const OFF_AT = [5, 11, 16];
const PAGES = 42;

const DEFAULT_PROMPT = 'Make the carve-out mutual and cap data-breach claims at 2× fees';

/** How long the chart's own entrance runs, and when its percentiles count. */
const COUNT_FROM = 1150;
const COUNT_FOR = 850;

type Drafting = { index: number; progress: number; status: 'running' | 'stopped' | 'done' };

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

export function RedlineReview({
  title = 'Larkspur MSA',
  file = 'Larkspur_MSA_v4.pdf',
  playbook = 'SaaS vendor v3',
  draftMonths = 3,
  redlineMonths = 12,
  playbookMonths = 12,
  defaultPrompt = DEFAULT_PROMPT,
  onDecision,
  onSend,
  autoplay: autoplayProp = true,
  defaultState,
  colorScheme,
  className = '',
}: {
  /** The contract's name, over its version, length and playbook. */
  title?: string;
  /** The file the agent read. */
  file?: string;
  /** The playbook it was checked against. */
  playbook?: string;
  /** Their draft's liability cap, in months (0 to 25). */
  draftMonths?: number;
  /** The redline's cap, in months. */
  redlineMonths?: number;
  /** The playbook's minimum, in months: contracts below it are grey. */
  playbookMonths?: number;
  /** What the composer holds at first. */
  defaultPrompt?: string;
  /** Called when the redline is accepted, rejected, or opened again. */
  onDecision?: (decision: RedlineDecision) => void;
  /** Called with the composer's text and mode when it is sent. */
  onSend?: (prompt: string, mode: ComposerMode) => void;
  /** While on screen and untouched, a demo plays the review from the start, over and over. */
  autoplay?: boolean;
  /** Opens in this state (steps, tab, decision, mode, drafting, hover) with the demo off. */
  defaultState?: RedlineReviewState;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const autoplay = autoplayProp && !defaultState;
  const steps = defaultState?.steps ?? 3;
  const { ref: root, live, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const id = useId();
  const [tab, setTab] = useState<'review' | 'redlines'>(defaultState?.tab ?? 'redlines');
  /** How far the steps are: 0 none, 1 reading, 2 checking, 3 both done. */
  const [reading, setReading] = useState<number>(steps);
  const [pages, setPages] = useState(steps === 1 ? 24 : PAGES);
  const [checked, setChecked] = useState(steps === 1 ? 0 : steps === 2 ? 10 : CLAUSES);
  /** The chart: hidden, playing its entrance (key replays it), or drawn at rest. */
  const [chart, setChart] = useState<'hidden' | 'play' | 'still'>(steps < 3 ? 'hidden' : 'still');
  const [chartKey, setChartKey] = useState(0);
  /** How far the percentiles have counted, 0 to 1. */
  const [counted, setCounted] = useState(1);
  const [decision, setDecision] = useState<RedlineDecision>(defaultState?.decision ?? 'open');
  /** A button the demo is pressing. */
  const [pressed, setPressed] = useState<'accept' | 'reject' | 'send' | null>(null);
  const [prompt, setPrompt] = useState(defaultPrompt);
  const [mode, setMode] = useState<ComposerMode>(defaultState?.mode ?? 'redline');
  const [drafting, setDrafting] = useState<Drafting>(() =>
    defaultState?.drafting === 'done'
      ? { index: QUEUE.length - 1, progress: 1, status: 'done' }
      : { index: 1, progress: 0.42, status: defaultState?.drafting ?? 'running' },
  );
  /** The drafting line moves on its own once the reader or the demo has set it going. */
  const [moving, setMoving] = useState(autoplay);
  const [fading, setFading] = useState(false);
  const [hover, setHover] = useState<Column | null>(() => {
    const at = defaultState?.hoverMonths;
    if (at === undefined) return null;
    return COLUMNS.reduce((best, column) =>
      Math.abs(column.months - at) < Math.abs(best.months - at) ? column : best,
    );
  });
  const [message, setMessage] = useState('');
  const field = useRef<HTMLTextAreaElement>(null);
  const tabs = useRef<(HTMLButtonElement | null)[]>([]);
  const actions = useRef<HTMLDivElement>(null);
  /** The decision buttons swap: the focus moves to the first new one when a reader pressed. */
  const refocus = useRef(false);

  const demo = autoplay && playing;
  const announce = (text: string) => setMessage((now) => (now === text ? `${text} ` : text));

  const draftAt = draftMonths * PX_PER_MONTH;
  const redlineAt = redlineMonths * PX_PER_MONTH;
  const playbookAt = playbookMonths * PX_PER_MONTH;
  const draftP = percentile(draftMonths);
  const redlineP = percentile(redlineMonths);
  const shownDraftP = Math.round(draftP * counted);
  const shownRedlineP = Math.round(redlineP * counted);
  const ok = checked - OFF_AT.filter((at) => at < checked).length;
  const off = checked - ok;
  const waiting = QUEUE.length - (decision === 'open' ? 0 : 1);

  // The percentiles count up while the chart plays its entrance.
  useEffect(() => {
    if (chart !== 'play') {
      setCounted(chart === 'still' ? 1 : 0);
      return;
    }
    setCounted(0);
    let frame = 0;
    const start = performance.now() + COUNT_FROM;
    const tick = (now: number) => {
      const t = Math.min(1, Math.max(0, (now - start) / COUNT_FOR));
      // Ease out: fast at first, settling on the number.
      setCounted(1 - (1 - t) ** 3);
      if (t < 1) frame = requestAnimationFrame(tick);
    };
    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
  }, [chart, chartKey]);

  // The drafting line moves on while on screen: a little every 450 ms, then the next redline.
  const advance = useEffectEvent(() => {
    setDrafting((now) => {
      if (now.status !== 'running') return now;
      const progress = now.progress + 0.03 + Math.random() * 0.035;
      if (progress < 1) return { ...now, progress };
      if (now.index < QUEUE.length - 1) return { ...now, index: now.index + 1, progress: 0 };
      announce(`All ${QUEUE.length} redlines drafted.`);
      return { ...now, progress: 1, status: 'done' };
    });
  });
  useEffect(() => {
    if (!live || !moving || drafting.status !== 'running') return;
    const timer = window.setInterval(advance, 450);
    return () => window.clearInterval(timer);
  }, [live, moving, drafting.status]);

  // The demo: the review plays from the start, then over again.
  const set = useEffectEvent(
    (
      apply: (actions: {
        setReading: typeof setReading;
        setPages: typeof setPages;
        setChecked: typeof setChecked;
        setChart: typeof setChart;
        setChartKey: typeof setChartKey;
        setDecision: typeof setDecision;
        setPressed: typeof setPressed;
        setPrompt: typeof setPrompt;
        setDrafting: typeof setDrafting;
        setFading: typeof setFading;
        setTab: typeof setTab;
        setMoving: typeof setMoving;
      }) => void,
    ) =>
      apply({
        setReading,
        setPages,
        setChecked,
        setChart,
        setChartKey,
        setDecision,
        setPressed,
        setPrompt,
        setDrafting,
        setFading,
        setTab,
        setMoving,
      }),
  );
  const decide = useEffectEvent((next: RedlineDecision) => {
    setDecision(next);
    onDecision?.(next);
  });
  useEffect(() => {
    if (!demo) return;
    let alive = true;
    const timers = new Set<number>();
    const wait = (ms: number) =>
      new Promise<void>((resolve, reject) => {
        if (!alive) return reject(new Stopped());
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          if (alive) resolve();
          else reject(new Stopped());
        }, ms);
        timers.add(timer);
      });

    const script = async () => {
      for (;;) {
        // Fade the old review out, then start from nothing.
        set((a) => a.setFading(true));
        await wait(300);
        set((a) => {
          a.setTab('redlines');
          a.setReading(0);
          a.setPages(0);
          a.setChecked(0);
          a.setChart('hidden');
          a.setDecision('open');
          a.setPressed(null);
          a.setPrompt('');
          a.setDrafting({ index: 1, progress: 0.06, status: 'running' });
          a.setMoving(true);
          a.setFading(false);
        });
        await wait(350);
        // Reading: the page count runs up.
        set((a) => a.setReading(1));
        for (let page = 3; page <= PAGES; page += 3) {
          await wait(55);
          set((a) => a.setPages(page));
        }
        await wait(260);
        // Checking: clause after clause, three of them off.
        set((a) => a.setReading(2));
        for (let clause = 1; clause <= CLAUSES; clause += 1) {
          await wait(45);
          set((a) => a.setChecked(clause));
        }
        await wait(260);
        set((a) => a.setReading(3));
        await wait(240);
        set((a) => {
          a.setChart('play');
          a.setChartKey((key) => key + 1);
        });
        await wait(2700);
        // An instruction, typed into the composer.
        for (let length = 1; length <= defaultPrompt.length; length += 1) {
          await wait(24 + Math.random() * 30);
          set((a) => a.setPrompt(defaultPrompt.slice(0, length)));
        }
        await wait(700);
        set((a) => a.setPressed('accept'));
        await wait(180);
        set((a) => a.setPressed(null));
        decide('accepted');
        await wait(1500);
        set((a) => a.setPressed('send'));
        await wait(180);
        set((a) => {
          a.setPressed(null);
          a.setPrompt('');
        });
        await wait(1800);
      }
    };
    script().catch((error: unknown) => {
      if (!(error instanceof Stopped)) throw error;
    });
    return () => {
      alive = false;
      timers.forEach((timer) => window.clearTimeout(timer));
      // Whatever the demo left half done is finished at once.
      setFading(false);
      setPressed(null);
      setReading(3);
      setPages(PAGES);
      setChecked(CLAUSES);
      setChart((now) => (now === 'hidden' ? 'still' : now));
    };
  }, [demo, defaultPrompt]);

  useEffect(() => {
    if (!refocus.current) return;
    refocus.current = false;
    actions.current?.querySelector<HTMLButtonElement>('button:last-child')?.focus();
  }, [decision]);

  const choose = (next: RedlineDecision) => {
    refocus.current = actions.current?.contains(document.activeElement) ?? false;
    decide(next);
    announce(
      next === 'accepted'
        ? `Redline accepted: the cap is ${redlineMonths} months, p${redlineP} of the market.`
        : next === 'rejected'
          ? `Redline rejected: their draft stands at ${draftMonths} months, p${draftP}.`
          : 'Redline opened again.',
    );
  };

  const send = (event?: FormEvent) => {
    event?.preventDefault();
    const text = prompt.trim();
    if (!text) return;
    onSend?.(text, mode);
    setPrompt('');
    setMoving(true);
    setDrafting((now) =>
      now.status === 'running' ? now : { index: QUEUE.length - 1, progress: 0, status: 'running' },
    );
    announce(
      mode === 'redline' ? `Redline instruction sent: ${text}` : `Comparison asked: ${text}`,
    );
  };

  const stopDrafting = () => {
    if (drafting.status === 'running') {
      setDrafting((now) => ({ ...now, status: 'stopped' }));
      announce(`Drafting stopped at ${QUEUE[drafting.index]}.`);
    } else {
      setMoving(true);
      setDrafting((now) =>
        now.status === 'done'
          ? { index: 0, progress: 0, status: 'running' }
          : { ...now, status: 'running' },
      );
      announce(`Drafting ${drafting.status === 'done' ? 'again' : 'resumed'}.`);
    }
  };

  const onTabKey = (event: ReactKeyboardEvent) => {
    if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
    event.preventDefault();
    const next =
      event.key === 'Home'
        ? 'review'
        : event.key === 'End'
          ? 'redlines'
          : tab === 'review'
            ? 'redlines'
            : 'review';
    setTab(next);
    tabs.current[next === 'review' ? 0 : 1]?.focus();
  };

  // The chart's hover: the column under a mouse or pen, with its count.
  const onChartMove = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (event.pointerType === 'touch') return;
    const rect = event.currentTarget.getBoundingClientRect();
    const x = ((event.clientX - rect.left) / rect.width) * VIEW_W;
    let best: Column | null = null;
    for (const column of COLUMNS)
      if (
        Math.abs(column.x - x) <= PITCH * 0.75 &&
        (!best || Math.abs(column.x - x) < Math.abs(best.x - x))
      )
        best = column;
    setHover(best);
  };

  const spot = (x: number, y = AXIS_Y): CSSProperties => ({
    left: `${(x / VIEW_W) * 100}%`,
    top: `${(y / VIEW_H) * 100}%`,
  });

  const draftingLabel = QUEUE[drafting.index];

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="stage"
      data-color-scheme={colorScheme}
      data-live={live}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onWheelCapture={take}
    >
      <article
        className={styles.card}
        data-part="card"
        data-fading={fading}
        data-decision={decision}
        aria-labelledby={`${id}-title`}
      >
        <header className={styles.header} data-part="header">
          <span className={styles.docIcon} aria-hidden="true">
            <svg viewBox="0 0 12 14" focusable="false">
              <path d="M2.2 1h4.9L10 3.9v8a1.1 1.1 0 0 1-1.1 1.1H2.2a1.1 1.1 0 0 1-1.1-1.1V2.1A1.1 1.1 0 0 1 2.2 1Z" />
              <path d="M3.6 7h4.6M3.6 9.4h3" />
            </svg>
          </span>
          <div className={styles.heading}>
            <h3 id={`${id}-title`} className={styles.title}>
              {title}
            </h3>
            <p className={styles.meta}>
              v4 · {PAGES} pages · Playbook {playbook}
            </p>
          </div>
          <div
            className={styles.tabs}
            role="tablist"
            aria-label="View"
            data-part="tabs"
            onKeyDown={onTabKey}
          >
            {(['review', 'redlines'] as const).map((name, index) => (
              <button
                key={name}
                ref={(element) => {
                  tabs.current[index] = element;
                }}
                type="button"
                role="tab"
                id={`${id}-tab-${name}`}
                aria-selected={tab === name}
                aria-controls={`${id}-panel`}
                tabIndex={tab === name ? 0 : -1}
                className={styles.tab}
                data-part="tab"
                onClick={() => setTab(name)}
              >
                {name === 'review' ? 'Review' : 'Redlines'}
                {name === 'redlines' && (
                  <span className={styles.count} aria-label={`, ${waiting} waiting`}>
                    {waiting}
                  </span>
                )}
              </button>
            ))}
          </div>
        </header>

        <ol className={styles.steps} aria-label="Review steps" data-part="steps">
          <li
            className={styles.step}
            data-part="step"
            data-shown={reading >= 1}
            data-done={reading >= 2}
          >
            <StepIcon done={reading >= 2} />
            <p className={styles.stepText}>
              <span className={styles.quiet}>{reading >= 2 ? 'Read' : 'Reading'}</span>{' '}
              <span className={styles.strong}>{file}</span>{' '}
              <span className={styles.green}>
                {pages} pages{reading >= 2 && ` · ${CLAUSES} clauses`}
              </span>
            </p>
          </li>
          <li className={styles.step} data-shown={reading >= 2} data-done={reading >= 3}>
            <StepIcon done={reading >= 3} />
            <p className={styles.stepText}>
              <span className={styles.quiet}>
                {reading >= 3 ? 'Checked against' : 'Checking against'}
              </span>{' '}
              <span className={styles.strong}>{playbook}</span>{' '}
              <span className={styles.mono}>
                <span className={styles.green}>{ok} ok</span>
                <span className={styles.quiet}> · </span>
                <span className={styles.red}>{off} off</span>
              </span>
            </p>
          </li>
        </ol>

        <div
          id={`${id}-panel`}
          role="tabpanel"
          aria-labelledby={`${id}-tab-${tab}`}
          className={styles.panel}
          data-part="panel"
          data-chart={chart}
        >
          {tab === 'redlines' ? (
            <section key={chartKey} className={styles.clause} data-part="clause" data-chart={chart}>
              <div className={styles.clauseHead}>
                <h4 className={styles.clauseTitle}>
                  <span className={styles.section}>§ 9.2</span> Liability cap vs market
                </h4>
                <p className={styles.scale}>1 dot = {PER_DOT} SaaS MSAs</p>
              </div>

              <div
                className={styles.chart}
                data-part="chart"
                role="img"
                aria-label={`Liability caps in ${COLUMNS.reduce((n, c) => n + c.dots, 0) * PER_DOT} SaaS master agreements, 0 to 26 months. Their draft, ${draftMonths} months, is at p${draftP}; the playbook minimum is ${playbookMonths} months; the redline, ${redlineMonths} months, is at p${redlineP}.`}
                onPointerMove={onChartMove}
                onPointerLeave={() => setHover(null)}
              >
                <Swarm playbookAt={playbookAt} />
                <svg
                  className={styles.overlay}
                  viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
                  preserveAspectRatio="none"
                  aria-hidden="true"
                  focusable="false"
                >
                  <defs>
                    <mask id={`${id}-arc`} maskUnits="userSpaceOnUse">
                      <path
                        className={styles.arcMask}
                        d={arcPath(draftAt, redlineAt)}
                        pathLength={1}
                      />
                    </mask>
                  </defs>
                  {hover && (
                    <rect
                      className={styles.band}
                      x={hover.x - PITCH / 2}
                      y={AXIS_Y - hover.up * PITCH - PITCH / 2 - 1.5}
                      width={PITCH}
                      height={hover.dots * PITCH + 3}
                      rx={PITCH / 2}
                    />
                  )}
                  <line
                    className={styles.playbook}
                    x1={playbookAt}
                    x2={playbookAt}
                    y1={3}
                    y2={116}
                  />
                  <g mask={`url(#${id}-arc)`}>
                    <path className={styles.arc} d={arcPath(draftAt, redlineAt)} />
                  </g>
                  <path
                    className={styles.arrow}
                    d={`M${redlineAt - 3.6} 40.2L${redlineAt} 46L${redlineAt + 3.6} 40.2`}
                  />
                  <g
                    className={styles.draft}
                    style={{ transformOrigin: `${draftAt}px ${AXIS_Y}px` }}
                  >
                    <circle className={styles.knockout} cx={draftAt} cy={AXIS_Y} r={7.4} />
                    <circle className={styles.draftRing} cx={draftAt} cy={AXIS_Y} r={6} />
                  </g>
                  <g
                    className={styles.redline}
                    style={{ transformOrigin: `${redlineAt}px ${AXIS_Y}px` }}
                  >
                    <circle className={styles.knockout} cx={redlineAt} cy={AXIS_Y} r={10.4} />
                    <circle className={styles.redlineRing} cx={redlineAt} cy={AXIS_Y} r={8.2} />
                    <circle className={styles.redlineDot} cx={redlineAt} cy={AXIS_Y} r={5} />
                  </g>
                </svg>
                <span
                  className={styles.playbookLabel}
                  data-part="playbook-label"
                  style={spot(playbookAt, 0)}
                  aria-hidden="true"
                >
                  Playbook min
                </span>
                {hover && (
                  <span
                    className={styles.tip}
                    data-part="tooltip"
                    style={spot(hover.x, AXIS_Y - hover.up * PITCH - PITCH)}
                    aria-hidden="true"
                  >
                    <span className={styles.mono}>{hover.months.toFixed(1)} mo</span> ·{' '}
                    {hover.dots * PER_DOT} MSAs
                  </span>
                )}
              </div>

              <div className={styles.axis} aria-hidden="true">
                {[0, 6, 12, 18, 24].map((months) => (
                  <span
                    key={months}
                    className={styles.tickLabel}
                    data-edge={months === 0 ? 'start' : months === 24 ? 'end' : undefined}
                    style={{ left: `${((months * PX_PER_MONTH) / VIEW_W) * 100}%` }}
                  >
                    {months}
                    {months === 24 && <span className={styles.unit}> mo</span>}
                  </span>
                ))}
              </div>

              <ul className={styles.legend} data-part="legend">
                <li className={styles.legendItem}>
                  <span className={styles.keyDraft} aria-hidden="true" />
                  <span>
                    Their draft{' '}
                    <span className={styles.strong}>
                      {draftMonths}
                      <span className={styles.mono}> mo · </span>
                      <Counted value={shownDraftP} final={draftP} />
                    </span>
                  </span>
                </li>
                <li className={styles.legendItem}>
                  <span className={styles.keyRedline} aria-hidden="true" />
                  <span>
                    Redline{' '}
                    <span className={styles.strong}>
                      <span className={styles.mono}>{redlineMonths} mo · </span>
                      <Counted value={shownRedlineP} final={redlineP} />
                    </span>
                  </span>
                </li>
                <li className={styles.legendItem}>
                  <span className={styles.keyBelow} aria-hidden="true" />
                  <span>Below playbook</span>
                </li>
              </ul>

              <div className={styles.footer} data-part="decision">
                <p className={styles.note}>
                  {decision === 'accepted' ? (
                    <CheckGlyph className={styles.noteCheck} />
                  ) : (
                    <Sparkle className={styles.sparkle} />
                  )}
                  <span>
                    {decision === 'open' && (
                      <>
                        Redline moves the cap from{' '}
                        <span className={styles.red}>
                          <Counted value={shownDraftP} final={draftP} />
                        </span>{' '}
                        to{' '}
                        <span className={styles.strong}>
                          <Counted value={shownRedlineP} final={redlineP} />
                        </span>{' '}
                        of the market
                      </>
                    )}
                    {decision === 'accepted' && (
                      <>
                        Accepted: the cap is{' '}
                        <span className={styles.strong}>{redlineMonths} mo</span>,{' '}
                        <span className={styles.strong}>p{redlineP}</span> of the market
                      </>
                    )}
                    {decision === 'rejected' && (
                      <>
                        Rejected: their draft stands at{' '}
                        <span className={styles.red}>
                          {draftMonths} mo, p{draftP}
                        </span>
                      </>
                    )}
                  </span>
                </p>
                <div ref={actions} className={styles.actions}>
                  {decision === 'open' ? (
                    <>
                      <button
                        type="button"
                        className={styles.secondary}
                        data-part="reject"
                        data-pressed={pressed === 'reject'}
                        onClick={() => choose('rejected')}
                      >
                        Reject
                      </button>
                      <button
                        type="button"
                        className={styles.primary}
                        data-part="accept"
                        data-pressed={pressed === 'accept'}
                        onClick={() => choose('accepted')}
                      >
                        Accept
                      </button>
                    </>
                  ) : (
                    <button
                      type="button"
                      className={styles.secondary}
                      onClick={() => choose('open')}
                    >
                      Undo
                    </button>
                  )}
                </div>
              </div>
            </section>
          ) : (
            <section className={styles.clause} data-part="clause-list">
              <div className={styles.clauseHead}>
                <h4 className={styles.clauseTitle}>Off playbook</h4>
                <p className={styles.scale}>
                  {CLAUSES - FLAGGED.length} of {CLAUSES} clauses ok
                </p>
              </div>
              <ul className={styles.flagged}>
                {FLAGGED.map((item, index) => (
                  <li key={item.clause}>
                    <button
                      type="button"
                      className={styles.flag}
                      data-part="flag"
                      onClick={() => setTab('redlines')}
                      disabled={index !== 0}
                    >
                      <span className={styles.section}>{item.clause}</span>
                      <span className={styles.flagTitle}>{item.title}</span>
                      <span className={styles.flagDetail}>{item.detail}</span>
                      <span
                        className={styles.flagState}
                        data-state={flagState(index, decision, drafting)}
                      >
                        {flagLabel(index, decision, drafting)}
                      </span>
                    </button>
                  </li>
                ))}
              </ul>
            </section>
          )}
        </div>

        <div className={styles.drafting} data-part="drafting" data-status={drafting.status}>
          <div className={styles.draftingRow}>
            {drafting.status === 'done' ? (
              <CheckGlyph className={styles.draftingDone} />
            ) : (
              <Spinner className={styles.spinner} />
            )}
            <p className={styles.draftingText}>
              {drafting.status === 'done'
                ? 'Drafted every redline'
                : `${drafting.status === 'stopped' ? 'Stopped' : 'Drafting'} ${draftingLabel}`}{' '}
              <span className={styles.draftingCount}>
                {drafting.index + 1} of {QUEUE.length}
              </span>
            </p>
            <button
              type="button"
              className={styles.cancel}
              data-part="cancel"
              onClick={stopDrafting}
            >
              {drafting.status === 'running'
                ? 'Cancel'
                : drafting.status === 'stopped'
                  ? 'Resume'
                  : 'Redraft'}
            </button>
          </div>
          <form className={styles.composer} data-part="composer" onSubmit={send}>
            <div
              className={styles.progress}
              data-part="progress"
              role="progressbar"
              aria-label={`Drafting ${draftingLabel}`}
              aria-valuemin={0}
              aria-valuemax={100}
              aria-valuenow={Math.round(drafting.progress * 100)}
            >
              <span
                className={styles.progressFill}
                style={{ transform: `scaleX(${drafting.progress.toFixed(3)})` }}
              />
            </div>
            <textarea
              ref={field}
              className={styles.field}
              data-part="field"
              value={prompt}
              rows={1}
              aria-label="Instruction for the drafter"
              placeholder={
                mode === 'redline' ? 'Describe a redline…' : 'Compare with the playbook or a draft…'
              }
              onChange={(event) => setPrompt(event.target.value)}
              onKeyDown={(event) => {
                if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
                  event.preventDefault();
                  send();
                }
              }}
            />
            <div className={styles.composerBar}>
              <button
                type="button"
                className={styles.round}
                aria-label="Cite a clause"
                onClick={() => {
                  setPrompt((now) => `${now}${now && !now.endsWith(' ') ? ' ' : ''}§ `);
                  field.current?.focus();
                }}
              >
                <svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
                  <path d="M8 2.5v11M2.5 8h11" />
                </svg>
              </button>
              <div className={styles.modes} data-part="modes" role="radiogroup" aria-label="Mode">
                {(['redline', 'compare'] as const).map((name) => (
                  <button
                    key={name}
                    type="button"
                    role="radio"
                    aria-checked={mode === name}
                    tabIndex={mode === name ? 0 : -1}
                    className={styles.mode}
                    onClick={() => setMode(name)}
                    onKeyDown={(event) => {
                      if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) {
                        event.preventDefault();
                        const next = mode === 'redline' ? 'compare' : 'redline';
                        setMode(next);
                        (
                          event.currentTarget.parentElement?.querySelector(
                            `[data-mode="${next}"]`,
                          ) as HTMLElement | null
                        )?.focus();
                      }
                    }}
                    data-mode={name}
                  >
                    {name === 'redline' ? 'Redline' : 'Compare'}
                  </button>
                ))}
              </div>
              <button
                type="submit"
                className={styles.send}
                data-part="send"
                aria-label="Send"
                disabled={!prompt.trim()}
                data-pressed={pressed === 'send'}
              >
                <svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
                  <path d="M8 13V3.2M3.6 7.4 8 3l4.4 4.4" />
                </svg>
              </button>
            </div>
          </form>
        </div>
      </article>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}

function flagState(index: number, decision: RedlineDecision, drafting: Drafting) {
  if (index === 0) return decision === 'open' ? 'ready' : decision;
  if (drafting.status === 'done' || drafting.index > index) return 'ready';
  return drafting.index === index ? 'drafting' : 'queued';
}
function flagLabel(index: number, decision: RedlineDecision, drafting: Drafting) {
  const state = flagState(index, decision, drafting);
  return state === 'ready'
    ? 'Redline ready'
    : state === 'accepted'
      ? 'Accepted'
      : state === 'rejected'
        ? 'Rejected'
        : state === 'drafting'
          ? 'Drafting'
          : 'Queued';
}

/**
 * The market's dots, drawn once: they never change after the first render, so hovering and
 * counting leave them alone.
 */
const Swarm = memo(function Swarm({ playbookAt }: { playbookAt: number }) {
  return (
    <svg
      className={styles.swarm}
      viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
      preserveAspectRatio="none"
      aria-hidden="true"
      focusable="false"
    >
      <line className={styles.axisLine} x1={0} x2={VIEW_W} y1={AXIS_Y} y2={AXIS_Y} />
      {DOTS.map((dot) => (
        <circle
          key={`${dot.x}-${dot.y}`}
          className={dot.x < playbookAt - 0.01 ? styles.below : styles.above}
          cx={dot.x}
          cy={dot.y}
          r={DOT_R}
          style={{ '--o': dot.opacity, '--d': `${dot.delay}ms` } as CSSProperties}
        />
      ))}
    </svg>
  );
});

/**
 * A percentile while it counts: the final number holds its place, unseen, so the words around it
 * never move.
 */
function Counted({ value, final }: { value: number; final: number }) {
  return (
    <span className={styles.counted}>
      <span className={styles.countedGhost} aria-hidden="true">
        p{final}
      </span>
      <span>p{value}</span>
    </span>
  );
}

function StepIcon({ done }: { done: boolean }) {
  return (
    <span className={styles.stepIcon} data-done={done} aria-hidden="true">
      <Spinner className={styles.stepSpinner} />
      <svg className={styles.stepCheck} viewBox="0 0 16 16" focusable="false">
        <path d="M3.6 8.3 6.6 11.2 12.4 5" />
      </svg>
    </span>
  );
}

function CheckGlyph({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true" focusable="false">
      <path d="M3.6 8.3 6.6 11.2 12.4 5" />
    </svg>
  );
}

/** Eight rays, each a step fainter, turned in steps of an eighth. */
function Spinner({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true" focusable="false">
      {Array.from({ length: 8 }, (_, ray) => (
        <line
          key={ray}
          x1={8}
          y1={1.5}
          x2={8}
          y2={4.8}
          transform={`rotate(${ray * 45} 8 8)`}
          style={{ opacity: 1 - ray * 0.1 }}
        />
      ))}
    </svg>
  );
}

/** The assistant's mark: a four-pointed star with a small one beside it. */
function Sparkle({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true" focusable="false">
      <path d="M6.5 2.2c.35 2.6 1.4 3.65 4 4-2.6.35-3.65 1.4-4 4-.35-2.6-1.4-3.65-4-4 2.6-.35 3.65-1.4 4-4Z" />
      <path d="M12 9.8c.15 1.05.55 1.45 1.6 1.6-1.05.15-1.45.55-1.6 1.6-.15-1.05-.55-1.45-1.6-1.6 1.05-.15 1.45-.55 1.6-1.6Z" />
    </svg>
  );
}
```

### redline-review/market.ts

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

/*
 * The market the liability cap is weighed against: 1,458 invented SaaS master agreements, one dot
 * for every two, stacked in columns along a 0 to 26 month axis. Each column stands for a sliver of
 * months (the dots' pitch over the axis' scale) and stacks above and below the axis line.
 *
 * Everything here is in the chart's own units: the drawing is VIEW_W × VIEW_H and the SVG scales
 * it to the card's width.
 */

/** The drawing's size, and where its axis runs. */
export const VIEW_W = 527;
export const VIEW_H = 118;
export const AXIS_Y = 61.75;
/** Px per month along the axis (0 months sits at x = 0), and the distance between dots. */
export const PX_PER_MONTH = 20.333;
export const PITCH = 4.6;
export const DOT_R = 1.8;
/** Contracts per dot. */
export const PER_DOT = 2;

/**
 * The columns, from 0.92 to 25.56 months, each a [dots above, dots below] pair besides the one on
 * the axis; null for a sliver with no contract at all. The column at index ANCHOR sits at 12 months.
 */
const STACKS = `0/0 x 0/0 0/1 0/1 0/0 1/2 2/3 3/3 2/3 2/2 3/4 4/5 1/2 2/3 x 1/2 3/4 2/3 1/1 2/2 4/4
2/2 2/2 6/6 3/3 2/3 4/4 3/3 2/2 3/4 0/1 4/4 2/3 2/2 2/2 3/3 3/3 4/4 4/5 5/5 6/7 5/6 8/8 4/5 6/7 7/8
8/9 7/7 8/9 6/7 5/5 6/6 3/4 6/6 7/7 2/2 3/3 3/3 4/5 3/3 0/1 4/5 1/2 1/1 2/2 3/3 1/1 3/4 0/1 2/2 1/1
2/2 2/2 6/7 2/2 1/2 5/6 2/3 3/3 2/2 1/2 2/2 1/2 3/3 2/3 2/3 1/1 2/2 0/1 0/1 2/3 1/1 2/2 1/1 3/4 1/1
1/2 2/2 2/3 2/3 1/2 1/2 2/2 3/4 2/3 2/2 2/2 1/2 0/1`
  .split(/\s+/)
  .map((pair) => (pair === 'x' ? null : (pair.split('/').map(Number) as [number, number])));
const ANCHOR = 49;
const ANCHOR_X = 12 * PX_PER_MONTH;

export type Column = {
  /** Its place along the axis, in the drawing's units, and in months. */
  x: number;
  months: number;
  up: number;
  down: number;
  /** How many dots: up + down + the one on the axis. */
  dots: number;
};

export const COLUMNS: Column[] = STACKS.flatMap((stack, index) => {
  if (!stack) return [];
  const x = ANCHOR_X + (index - ANCHOR) * PITCH;
  const [up, down] = stack;
  return [{ x, months: x / PX_PER_MONTH, up, down, dots: up + down + 1 }];
});

export const TOTAL_DOTS = COLUMNS.reduce((sum, column) => sum + column.dots, 0);

export type Dot = {
  x: number;
  y: number;
  months: number;
  /** Its row from the axis, 0 on it; the farther, the fainter. */
  row: number;
  opacity: number;
  /** When it lands, in ms after the chart starts to draw. */
  delay: number;
};

/** A stable pseudo-random share in [0, 1) for a whole number. */
const jitter = (seed: number) => {
  const value = Math.sin(seed * 12.9898) * 43758.5453;
  return value - Math.floor(value);
};

export const DOTS: Dot[] = COLUMNS.flatMap((column, index) => {
  const dots: Dot[] = [];
  for (let row = -column.up; row <= column.down; row += 1) {
    const distance = Math.abs(row);
    dots.push({
      x: column.x,
      y: AXIS_Y + row * PITCH,
      months: column.months,
      row: distance,
      opacity: Math.max(0.3, 1 - distance * 0.08),
      // The columns land left to right, each from its middle out, a little apart.
      delay: Math.round(120 + index * 6 + distance * 30 + jitter(index * 31 + row) * 90),
    });
  }
  return dots;
});

/** The column nearest a number of months. */
export function columnAt(months: number): Column {
  return COLUMNS.reduce((best, column) =>
    Math.abs(column.months - months) < Math.abs(best.months - months) ? column : best,
  );
}

/**
 * Where a cap of `months` ranks in the market, 0 to 100: the share of contracts with a lower cap,
 * counting half of those in its own column.
 */
export function percentile(months: number): number {
  const own = columnAt(months);
  let below = 0;
  for (const column of COLUMNS) if (column.x < own.x) below += column.dots;
  return Math.round((100 * (below + own.dots / 2)) / TOTAL_DOTS);
}

/**
 * The arc from their draft to the redline, over the swarm: it rises steeply off the draft, runs
 * high, and drops into the arrow on top of the redline's marker.
 */
export function arcPath(from: number, to: number): string {
  const span = to - from;
  const f = (n: number) => n.toFixed(1);
  return `M${f(from)} 45C${f(from + span * 0.05)} 12 ${f(from + span * 0.84)} -4 ${f(to - 1)} 44`;
}
```

### redline-review/use-autoplay.ts

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

'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. `live` is the same without the reader: what
 * moves by itself (a progress bar) may move then.
 */
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, live: visible && !reduced, playing: visible && !reduced && !taken };
}
```

### redline-review/redline-review.module.css

```css
/* Redline review (redline-review.tsx): a graphite stage with the card in its middle, 582 px at
   most, near-black at night and white by day; inside it a darker well for the chart and a grey
   tray for the drafting line and the composer.

   Colours are the tokens below, light-dark(light, dark): 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 is transforms and opacity: the dots fall by a translate, the arc draws through a mask's
   dash offset, the markers pop by a scale and the progress line stretches by scaleX. */

.root {
  /* The stage, the card, the chart's well, the drafting tray and the composer. */
  --stage: #ececec;
  --card: #fff;
  --card-ring: rgb(0 0 0 / 0.07);
  --well: #fafafa;
  --well-line: #ececec;
  --tray: #f4f4f4;
  --tray-line: #e6e6e6;
  --composer-top: #fff;
  --composer-bottom: #f7f7f7;
  --composer-line: #e4e4e4;
  --composer-edge: #d9d9d9;
  /* Words: strong, quiet and faint. */
  --ink: #141414;
  --ink-2: #707070;
  --ink-3: #a6a6a6;
  /* The pills: their track, the chosen one, and its edge. */
  --track: #eeeeee;
  --track-line: #e3e3e3;
  --chip: #fff;
  --chip-top: #fff;
  --chip-shadow: rgb(0 0 0 / 0.08);
  /* Round buttons and Reject. */
  --button: #fff;
  --button-line: #dedede;
  --button-top: #fff;
  /* Accept and Send: dark by day, light at night. */
  --solid-top: #3a3a3a;
  --solid-bottom: #141414;
  --solid-ink: #fff;
  /* The document's badge and the steps. */
  --badge: #f0f0f0;
  --badge-ink: #6b6b6b;
  --badge-fill: #dcdcdc;
  --green: #12a26a;
  --green-tint: #dcf5e9;
  --red: #e5484d;
  --violet: #5b55f2;
  --violet-soft: #7c78ff;
  --spinner: #6c6cf6;
  /* The chart: the dots below and from the playbook, the axis, the lines and the redline's ring. */
  --below: #8a8a8a;
  --above: #5d58f5;
  --axis: #e4e4e4;
  --guide: #b5b5b5;
  --arc: #9a9a9a;
  --ring: #1a1a1a;
  --band: rgb(0 0 0 / 0.06);
  --tip: #1a1a1a;
  --tip-ink: #fff;
  /* The progress glow. */
  --glow-1: #8b8ff0;
  --glow-2: #6b6af0;
  --glow-3: #9a5fc0;
  --glow-4: #d4505d;
  --focus: #5b8cff;
  position: relative;
  container-type: inline-size;
  display: grid;
  place-items: center;
  box-sizing: border-box;
  width: 100%;
  min-height: 640px;
  padding: 28px 16px;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--stage);
  color: var(--ink);
  font-family:
    Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    Arial,
    sans-serif;
  font-size: 14px;
  line-height: 20px;
  letter-spacing: -0.15px;
  font-feature-settings: normal;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
  --mono: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --stage: light-dark(#ececec, #1e1e1e);
    --card: light-dark(#fff, #111);
    --card-ring: light-dark(rgb(0 0 0 / 0.07), #212121);
    --well: light-dark(#fafafa, #0d0d0d);
    --well-line: light-dark(#ececec, #1d1d1d);
    --tray: light-dark(#f4f4f4, #1a1a1a);
    --tray-line: light-dark(#e6e6e6, #282828);
    --composer-top: light-dark(#fff, #212121);
    --composer-bottom: light-dark(#f7f7f7, #333);
    --composer-line: light-dark(#e4e4e4, #2f2f2f);
    --composer-edge: light-dark(#d9d9d9, #404040);
    --ink: light-dark(#141414, #fafafa);
    --ink-2: light-dark(#707070, #969696);
    --ink-3: light-dark(#a6a6a6, #636363);
    --track: light-dark(#eeeeee, #171717);
    --track-line: light-dark(#e3e3e3, #242424);
    --chip: light-dark(#fff, #363636);
    --chip-top: light-dark(#fff, #454545);
    --chip-shadow: light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.3));
    --button: light-dark(#fff, #262626);
    --button-line: light-dark(#dedede, #343434);
    --button-top: light-dark(#fff, #444);
    --solid-top: light-dark(#3a3a3a, #b0b0b0);
    --solid-bottom: light-dark(#141414, #fdfdfd);
    --solid-ink: light-dark(#fff, #080808);
    --badge: light-dark(#f0f0f0, #2d2d2d);
    --badge-ink: light-dark(#6b6b6b, #cccccc);
    --badge-fill: light-dark(#dcdcdc, #4a4a4a);
    --green: light-dark(#12a26a, #4ad898);
    --green-tint: light-dark(#dcf5e9, #0c2a17);
    --red: light-dark(#e5484d, #f45a60);
    --violet: light-dark(#5b55f2, #736eff);
    --violet-soft: light-dark(#7c78ff, #9a9cff);
    --spinner: light-dark(#6c6cf6, #9a9cff);
    --below: light-dark(#8a8a8a, #7d7d7d);
    --above: light-dark(#5d58f5, #7471ff);
    --axis: light-dark(#e4e4e4, #1e1e1e);
    --guide: light-dark(#b5b5b5, #626262);
    --arc: light-dark(#9a9a9a, #858585);
    --ring: light-dark(#1a1a1a, #e3e3e3);
    --band: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.08));
    --tip: light-dark(#1a1a1a, #f2f2f2);
    --tip-ink: light-dark(#fff, #111);
    --glow-1: light-dark(#8b8ff0, #4c4f9c);
    --glow-2: light-dark(#6b6af0, #706ff1);
    --glow-3: light-dark(#9a5fc0, #9861b5);
    --glow-4: light-dark(#d4505d, #bd4c60);
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.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;
}

.root button {
  font: inherit;
  color: inherit;
  -webkit-tap-highlight-color: transparent;
}
.root :focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.root p,
.root h3,
.root h4,
.root ol,
.root ul {
  margin: 0;
  padding: 0;
}
.root ol,
.root ul {
  list-style: none;
}

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

.mono {
  font-family: var(--mono);
  letter-spacing: 0;
}
.quiet {
  color: var(--ink-2);
}
.strong {
  color: var(--ink);
}
.green {
  color: var(--green);
}
.red {
  color: var(--red);
}

/* The card. */
.card {
  box-sizing: border-box;
  width: 100%;
  max-width: 582px;
  padding: 13px 11px 9.5px;
  border-radius: 22px;
  background: var(--card);
  box-shadow: 0 0 0 1px var(--card-ring);
}
.card[data-fading='true'] .steps,
.card[data-fading='true'] .panel,
.card[data-fading='true'] .composer {
  opacity: 0;
  transition: opacity 280ms ease;
}

/* The header: the document, its name, and the two tabs. */
.header {
  display: flex;
  align-items: center;
  gap: 12px;
  min-height: 32px;
}
.docIcon {
  display: grid;
  flex: none;
  place-items: center;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  background: var(--badge);
}
.docIcon svg {
  width: 10px;
  height: 12px;
  overflow: visible;
  fill: none;
  stroke: var(--badge-ink);
  stroke-width: 1.15;
  stroke-linejoin: round;
  stroke-linecap: round;
}
.docIcon svg path:first-child {
  fill: var(--badge-fill);
}
.heading {
  flex: 1;
  min-width: 0;
  /* Its two lines run a little taller than the header's 32 px, out of the row's way. */
  margin: -2px 0 -1px;
}
.title {
  overflow: hidden;
  font-size: 14px;
  font-weight: 500;
  line-height: 18px;
  letter-spacing: -0.3px;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.meta {
  overflow: hidden;
  margin-top: 1px !important;
  color: var(--ink-2);
  font-size: 12px;
  line-height: 16px;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.tabs {
  display: flex;
  flex: none;
  gap: 0;
  padding: 3px;
  border-radius: 999px;
  background: var(--track);
  box-shadow: inset 0 0 0 1px var(--track-line);
}
.tab {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  height: 26px;
  margin: 0;
  padding: 0 11.5px;
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: var(--ink-2) !important;
  font-size: 12px !important;
  font-weight: 450 !important;
  line-height: 16px !important;
  cursor: pointer;
  transition:
    background-color 200ms ease,
    color 200ms ease,
    box-shadow 200ms ease;
}
.tab[aria-selected='true'] {
  background: var(--chip);
  box-shadow:
    inset 0 1px 0 var(--chip-top),
    0 1px 2px var(--chip-shadow);
  color: var(--ink) !important;
}
.tab:hover:not([aria-selected='true']) {
  color: var(--ink) !important;
}
.count {
  color: var(--ink-2);
  font-variant-numeric: tabular-nums;
}

/* The agent's steps. */
.steps {
  display: grid;
  gap: 12px;
  margin-top: 18px !important;
}
.step {
  display: flex;
  align-items: center;
  gap: 12px;
  min-height: 24px;
  transition:
    opacity 260ms ease,
    transform 260ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.step[data-shown='false'] {
  opacity: 0;
  transform: translateY(4px);
}
.stepText {
  min-width: 0;
  color: var(--ink-2);
  letter-spacing: -0.15px;
  overflow-wrap: anywhere;
}
.stepText .strong {
  font-weight: 500;
}
.stepText .mono {
  margin-left: 3px;
  font-size: 14px;
  font-weight: 500;
  letter-spacing: 0.2px;
}
.stepIcon {
  position: relative;
  display: grid;
  flex: none;
  place-items: center;
  width: 24px;
  height: 24px;
  border-radius: 50%;
  background: var(--badge);
  transition: background-color 220ms ease;
}
.stepIcon[data-done='true'] {
  background: var(--green-tint);
}
.stepSpinner,
.stepCheck {
  position: absolute;
  inset: 0;
  margin: auto;
  width: 13px;
  height: 13px;
  transition:
    opacity 180ms ease,
    transform 280ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.stepCheck {
  width: 14px;
  height: 14px;
  fill: none;
  stroke: var(--green);
  stroke-width: 1.9;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.stepIcon[data-done='true'] .stepSpinner,
.stepIcon[data-done='false'] .stepCheck {
  opacity: 0;
  transform: scale(0.4);
}

/* Spinners: eight rays, turned an eighth at a time. */
.spinner,
.stepSpinner {
  stroke: var(--spinner);
  stroke-width: 1.7;
  stroke-linecap: round;
  animation: spin 800ms steps(8) infinite;
}
.root[data-live='false'] .spinner,
.root[data-live='false'] .stepSpinner {
  animation-play-state: paused;
}
@keyframes spin {
  to {
    rotate: 360deg;
  }
}

/* The clause's well: title, chart, axis, legend, and the decision. */
.panel {
  margin-top: 16px;
}
.clause {
  padding: 15px 16px 15.5px;
  border-radius: 12px;
  background: var(--well);
  box-shadow: inset 0 0 0 1px var(--well-line);
}
.clauseHead {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 2px 12px;
}
.clauseTitle {
  font-size: 14px;
  font-weight: 500;
  line-height: 20px;
  letter-spacing: -0.3px;
}
.section {
  margin-right: 3px;
  color: var(--ink-3);
  font-family: var(--mono);
  font-weight: 400;
  letter-spacing: 0.6px;
}
.scale {
  color: var(--ink-3);
  font-size: 12px;
  line-height: 16px;
}

.chart {
  position: relative;
  margin-top: 13px;
  aspect-ratio: 527 / 118;
}
.swarm,
.overlay {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}
.axisLine {
  stroke: var(--axis);
  stroke-width: 1;
}
.below,
.above {
  opacity: var(--o);
}
.below {
  fill: var(--below);
}
.above {
  fill: var(--above);
}
.band {
  fill: var(--band);
}
.playbook {
  stroke: var(--guide);
  stroke-width: 1;
  stroke-dasharray: 3 3;
}
.arc {
  fill: none;
  stroke: var(--arc);
  stroke-width: 1;
  stroke-dasharray: 2 2.6;
  stroke-linecap: round;
}
.arcMask {
  fill: none;
  stroke: #fff;
  stroke-width: 6;
  stroke-dasharray: 1 1;
  stroke-dashoffset: 0;
}
.arrow {
  fill: none;
  stroke: var(--arc);
  stroke-width: 1;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.knockout {
  fill: var(--well);
}
.draftRing {
  fill: none;
  stroke: var(--red);
  stroke-width: 2;
}
.redlineRing {
  fill: none;
  stroke: var(--ring);
  stroke-width: 2.4;
}
.redlineDot {
  fill: var(--above);
}
.draft,
.redline {
  transition: opacity 260ms ease;
}
.card[data-decision='accepted'] .draft,
.card[data-decision='rejected'] .redline,
.card[data-decision='rejected'] .arc,
.card[data-decision='rejected'] .arrow {
  opacity: 0.35;
}
.playbookLabel {
  position: absolute;
  margin: 0.5px 0 0 7.5px;
  color: var(--ink-3);
  font-size: 12px;
  line-height: 16px;
  white-space: nowrap;
}
.tip {
  position: absolute;
  z-index: 1;
  padding: 3px 7px;
  border-radius: 6px;
  background: var(--tip);
  color: var(--tip-ink);
  font-size: 11px;
  line-height: 14px;
  white-space: nowrap;
  transform: translate(-50%, calc(-100% - 6px));
  pointer-events: none;
}

.axis {
  position: relative;
  height: 16px;
  margin-top: 2.5px;
  color: var(--ink-3);
  font-family: var(--mono);
  font-size: 12px;
  line-height: 16px;
}
.tickLabel {
  position: absolute;
  top: 0;
  transform: translateX(-50%);
  white-space: nowrap;
}
.tickLabel[data-edge='start'] {
  transform: none;
}
/* The last label reads "24 mo", its number ending on its tick. */
.tickLabel[data-edge='end'] {
  transform: translateX(-2ch);
}
.counted {
  display: inline-grid;
  justify-items: start;
}
.counted > * {
  grid-area: 1 / 1;
}
.countedGhost {
  visibility: hidden;
}
.unit {
  white-space: pre;
}

.legend {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 19px;
  margin-top: 11px !important;
  color: var(--ink-2);
  font-size: 12px;
  line-height: 16px;
}
.legendItem {
  display: inline-flex;
  align-items: center;
  gap: 7.5px;
  white-space: nowrap;
}
.legendItem .mono {
  white-space: pre;
}
.keyDraft,
.keyRedline,
.keyBelow {
  display: grid;
  flex: none;
  place-items: center;
  box-sizing: border-box;
  width: 10px;
  height: 10px;
  border-radius: 50%;
}
.keyDraft {
  border: 2px solid var(--red);
}
.keyRedline {
  background: var(--above);
}
.keyBelow::before {
  content: '';
  width: 4px;
  height: 4px;
  border-radius: 50%;
  background: var(--below);
}

.footer {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 10px 16px;
  margin-top: 12px;
}
.note {
  display: flex;
  flex: 1 1 260px;
  align-items: center;
  gap: 8px;
  min-width: 0;
  color: var(--ink-2);
  font-size: 12px;
  line-height: 16px;
}
.sparkle,
.noteCheck {
  flex: none;
  width: 16px;
  height: 16px;
  margin-left: 1px;
}
.sparkle {
  fill: var(--badge-ink);
}
.noteCheck {
  fill: none;
  stroke: var(--green);
  stroke-width: 1.9;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.actions {
  display: flex;
  flex: none;
  gap: 8px;
  margin-left: auto;
}
.secondary,
.primary {
  height: 32px;
  margin: 0;
  border-radius: 999px;
  font-size: 14px !important;
  font-weight: 500 !important;
  line-height: 20px !important;
  letter-spacing: -0.15px;
  cursor: pointer;
  transition:
    transform 160ms ease,
    filter 160ms ease,
    background-color 160ms ease;
}
.secondary {
  padding: 0 11.25px;
  border: 1px solid var(--button-line);
  background: var(--button);
  box-shadow: inset 0 1px 0 var(--button-top);
  color: var(--ink) !important;
}
.primary {
  padding: 0 12px;
  border: 0;
  background: linear-gradient(var(--solid-top), var(--solid-bottom));
  color: var(--solid-ink) !important;
}
.secondary:hover,
.primary:hover {
  filter: brightness(1.08);
}
.secondary:active,
.primary:active,
.secondary[data-pressed='true'],
.primary[data-pressed='true'] {
  transform: scale(0.96);
}

/* The Review tab: the clauses that are off. */
.flagged {
  display: grid;
  gap: 2px;
  margin-top: 12px !important;
}
.flag {
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: 2px 10px;
  width: 100%;
  margin: 0;
  padding: 9px 10px;
  border: 0;
  border-radius: 10px;
  background: transparent;
  text-align: left;
  cursor: pointer;
}
.flag:disabled {
  cursor: default;
}
.flag:hover:not(:disabled) {
  background: var(--band);
}
.flag .section {
  grid-row: span 2;
  align-self: start;
}
.flagTitle {
  font-weight: 500;
}
.flagDetail {
  grid-column: 2;
  color: var(--ink-2);
  font-size: 12px;
  line-height: 16px;
}
.flagState {
  grid-column: 3;
  grid-row: 1 / span 2;
  color: var(--ink-2);
  font-size: 12px;
}
.flagState[data-state='ready'],
.flagState[data-state='accepted'] {
  color: var(--green);
}
.flagState[data-state='rejected'] {
  color: var(--red);
}
.flagState[data-state='drafting'] {
  color: var(--violet-soft);
}

/* The drafting tray: its row, the progress glow, and the composer. */
.drafting {
  position: relative;
  margin-top: 17px;
  padding: 0 4px 4px;
  border-radius: 16px;
  background: var(--tray);
  box-shadow: inset 0 0 0 1px var(--tray-line);
}
.draftingRow {
  display: flex;
  align-items: center;
  gap: 14px;
  min-height: 44px;
  padding: 0 12.5px 0 14px;
}
.spinner,
.draftingDone {
  flex: none;
  width: 14px;
  height: 14px;
}
.draftingDone {
  fill: none;
  stroke: var(--green);
  stroke-width: 1.9;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.draftingText {
  flex: 1;
  min-width: 0;
  color: var(--ink-2);
  letter-spacing: -0.27px;
}
.draftingCount {
  margin-left: 13px;
  color: var(--ink-3);
  font-family: var(--mono);
  letter-spacing: 0;
  white-space: nowrap;
}
.cancel {
  flex: none;
  margin: 0;
  padding: 4px 0;
  border: 0;
  background: none;
  color: var(--ink-2) !important;
  cursor: pointer;
  transition: color 160ms ease;
}
.cancel:hover {
  color: var(--ink) !important;
}

.progress {
  position: absolute;
  z-index: 1;
  top: -1.5px;
  right: 26px;
  left: 26px;
  height: 2px;
  pointer-events: none;
}
.progressFill {
  position: absolute;
  inset: 0;
  border-radius: 2px;
  background: linear-gradient(
    90deg,
    transparent,
    var(--glow-1) 22%,
    var(--glow-2) 44%,
    var(--glow-3) 53%,
    var(--glow-4) 70%,
    transparent
  );
  transform-origin: left;
  transition: transform 450ms linear;
}
.drafting[data-status='done'] .progressFill {
  opacity: 0;
  transition: opacity 400ms ease;
}

.composer {
  position: relative;
  display: grid;
  padding: 14px 11px 12.5px 16px;
  border: 1px solid var(--composer-line);
  border-bottom-color: var(--composer-edge);
  border-radius: 12px;
  background: linear-gradient(var(--composer-top), var(--composer-bottom));
}
.field {
  box-sizing: border-box;
  width: calc(100% + 1.5px);
  min-height: 20px;
  max-height: 100px;
  margin: 0 0 0 -1.5px;
  padding: 0;
  border: 0;
  outline: none;
  background: transparent;
  color: var(--ink);
  font: inherit;
  letter-spacing: -0.22px;
  resize: none;
  field-sizing: content;
}
.field::placeholder {
  color: var(--ink-3);
}
.composer:has(.field:focus-visible) {
  border-color: var(--focus);
}
.composerBar {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 18.5px;
}
.round,
.send {
  display: grid;
  flex: none;
  place-items: center;
  width: 32px;
  height: 32px;
  margin: 0;
  padding: 0;
  border-radius: 50%;
  cursor: pointer;
  transition:
    transform 160ms ease,
    filter 160ms ease,
    opacity 160ms ease;
}
.round {
  margin-right: auto;
  border: 1px solid var(--button-line);
  background: var(--chip);
}
.round svg,
.send svg {
  width: 16px;
  height: 16px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.round svg {
  width: 15px;
  height: 15px;
}
.send {
  border: 0;
  background: linear-gradient(var(--solid-top), var(--solid-bottom));
  color: var(--solid-ink) !important;
}
.send:disabled {
  opacity: 0.45;
  cursor: default;
}
.round:hover,
.send:hover:not(:disabled) {
  filter: brightness(1.08);
}
.round:active,
.send:active:not(:disabled),
.send[data-pressed='true'] {
  transform: scale(0.92);
}
.modes {
  display: flex;
  padding: 2px;
  border-radius: 999px;
  background: var(--track);
  box-shadow: inset 0 0 0 1px var(--track-line);
}
.mode {
  height: 26px;
  margin: 0;
  padding: 0 13px;
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: var(--ink-2) !important;
  font-size: 12px !important;
  font-weight: 450 !important;
  cursor: pointer;
  transition:
    background-color 200ms ease,
    color 200ms ease;
}
.mode[aria-checked='true'] {
  background: linear-gradient(var(--chip-top), var(--chip));
  color: var(--ink) !important;
}

/* The chart's entrance: the well rises in, the dots fall into place, then the playbook line, the
   draft's ring, the arc and the redline's dot, then the legend and the decision. */
.clause[data-chart='play'] {
  animation: rise 380ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.clause[data-chart='play'] .below,
.clause[data-chart='play'] .above {
  animation: fall 560ms cubic-bezier(0.25, 0.9, 0.3, 1.15) var(--d) both;
}
.clause[data-chart='play'] .playbook {
  transform-box: fill-box;
  transform-origin: top;
  animation: draw-down 420ms cubic-bezier(0.4, 0, 0.2, 1) 760ms both;
}
.clause[data-chart='play'] .playbookLabel {
  animation: fade 300ms ease 980ms both;
}
.clause[data-chart='play'] .draft {
  animation: pop 360ms cubic-bezier(0.34, 1.56, 0.64, 1) 950ms both;
}
.clause[data-chart='play'] .arcMask {
  animation: draw 820ms cubic-bezier(0.45, 0, 0.25, 1) 1200ms both;
}
.clause[data-chart='play'] .arrow {
  animation: fade 160ms ease 1960ms both;
}
.clause[data-chart='play'] .redline {
  animation: pop 420ms cubic-bezier(0.34, 1.56, 0.64, 1) 1980ms both;
}
.clause[data-chart='play'] .axis,
.clause[data-chart='play'] .legend {
  animation: fade 360ms ease 500ms both;
}
.clause[data-chart='play'] .footer {
  animation: rise 420ms cubic-bezier(0.2, 0.8, 0.2, 1) 2150ms both;
}
.panel[data-chart='hidden'] {
  visibility: hidden;
}

@keyframes rise {
  from {
    opacity: 0;
    transform: translateY(6px);
  }
}
@keyframes fade {
  from {
    opacity: 0;
  }
}
@keyframes fall {
  from {
    opacity: 0;
    transform: translateY(-26px);
  }
}
@keyframes draw-down {
  from {
    transform: scaleY(0);
  }
}
@keyframes draw {
  from {
    stroke-dashoffset: 1;
  }
}
@keyframes pop {
  from {
    opacity: 0;
    transform: scale(0.2);
  }
}

/* Narrow cards: the tabs keep their size, the words wrap. */
@container (max-width: 520px) {
  .header {
    flex-wrap: wrap;
    row-gap: 10px;
  }
  .tabs {
    order: 3;
  }
  .heading {
    flex-basis: calc(100% - 40px);
  }
}
@container (max-width: 420px) {
  .meta {
    white-space: normal;
  }
  .draftingCount {
    margin-left: 6px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .root *,
  .root *::before {
    animation: none !important;
    transition: none !important;
  }
}
```