# Contract dropzone

> The empty state of a contract-review assistant: drop a contract, paste a link or pick a source, and it reads every clause.

Nothing is in review yet. An isometric drawing of a contract, one clause lifted off it in mint, says what the assistant does; the composer under it takes the first contract. Drag a PDF or DOCX over the card, paste a link, or pull a sample from Drive, E-sign or Inbox, pick whose side to take, and send it: the card reads it clause by clause and counts one more review.

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

Category: AI.

## Anatomy

1. **Header**: A tray badge, Reviews and its count (Nothing in review yet, then 1 in review…), and the playbook the review checks against, on a pill.
2. **Illustration**: A contract in isometric lines with one clause lifted off it in mint. It floats while idle, lifts higher with a file over it, and its scan line quickens while reading.
3. **Copy**: A title and a line that follow the state: what to do, what will happen, why a file was turned down, the clause count, the result.
4. **Attachment**: The file to read, with its size and a remove button, in place of the link field. Removing it puts the focus back in the field.
5. **Attach**: Opens a file picker for the same PDF and DOCX files, so dragging is never required.
6. **Side**: Customer or Vendor: a radio group whose one thumb slides and resizes to the checked option. Arrow keys move it.
7. **Send**: Wakes up once there is a file or a link to read, and spins while reading.
8. **Sources**: Drive, E-sign and Inbox: each attaches a contract from that source in one press.

## When to use

- The first screen of a document-review tool, before anything is in review: one card says what the assistant does and takes the first file.
- Intake where a file, a pasted link and a connected source are all fine ways in, and the person picks whichever is at hand.
- A review that needs one decision up front, such as whose side to take, before the reading starts.
- A short, visible read (seconds, not minutes), where counting the clauses in place is more honest than a spinner.

## When not to use

- Once there are reviews to show. Use a list or table of them, such as the contracts table, and keep a small upload button in its header.
- Bulk uploads of many files at once. Use an upload queue with a row and a progress bar per file.
- Any file type, or files with no review behind them. Use a plain file input or a generic dropzone.
- Long jobs that run in the background. Hand off to a status page or a notification rather than holding the card on Reading.
- A form with several required fields. This takes one contract and one choice; a real form fits more.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/contract-dropzone.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 { ContractDropzone } from './contract-dropzone';

export function ReviewsEmptyState() {
  return (
    <ContractDropzone
      product="Vellum"
      playbook="Playbook v3"
      autoplay={false}
      onSubmit={({ file, link, side }) => startReview({ file, link, side })}
    />
  );
}
```

## States

- **Empty**: Nothing attached: the drawing idles, the field asks for a file or a link, Send waits.
- **File over the card**: The whole card is the target: the inset lights mint, the strip lifts 9 px, the copy says to let go.
- **Turned down**: Not a PDF or a DOCX: the inset’s edge and the title turn red, with the reason, for 2.8 s.
- **File attached**: An attachment chip replaces the field and Send wakes up. Here from the vendor’s side.
- **Link pasted**: A link in the field is enough to send; the copy names its last path segment.
- **Reading**: Clause by clause, 18 in 130 ms steps: the scan line quickens, Send spins, every control waits.
- **Done**: The result holds 2.6 s and the header counts one more in review, then the card is ready again.

## How it works

- The drawing is one inline SVG in the inset’s own px (480 × 223), so every point is placed where it lands on a full-width card and the whole of it scales below that. The lines of text are laid out on the top sheet through a bilinear map of its four corners, so they follow its slant.
- The idle motion is three transforms: the mint strip floats 3 px over its clause, its dotted guides stretch with it (each by its own length, from its foot), and a scan line sweeps the sheet every 5.6 s inside a clip of the sheet’s outline. It pauses off screen or in a hidden tab, and stays still under reduced motion.
- The whole card is the drop target. Drag events fire on every child the file crosses, so a counter of entries minus exits says whether the file is over it; only drags that carry files count. Over it, the inset lights mint, the strip lifts 9 px on a springy transition and the copy says to let go.
- A dropped or picked PDF or DOCX becomes an attachment chip in the field; anything else is turned down with the reason, for a moment, and screen readers hear it. The + button opens the same file picker, so nothing depends on dragging.
- Send wakes up once there is something to read: a file, or a link in the field. Sending counts 18 clauses in 130 ms steps while the scan line quickens and the button spins, then says what it found for 2.6 s; the header counts one more contract in review.
- Customer or Vendor is a radio group (arrow keys move it) with one thumb that slides and resizes to the checked option.
- While on screen and untouched, a demo pointer carries a file in, picks Vendor, sends it, then types a link and sends that too. It never takes the page’s focus, and any input hands control to the reader.
- Every colour is a light-dark() token on the root, so the card follows the page’s color-scheme, or the colorScheme prop: a near-black card with grey linework at night, a white one with soft greys by day, the mint clause on both.

## API reference

- **product** (`string`, default `'Vellum'`): The assistant’s name, in the copy under the drawing.
- **playbook** (`string`, default `'Playbook v3'`): The playbook the review checks against, shown on the header’s pill and in the copy.
- **defaultSide** (`'customer' | 'vendor'`, default `'customer'`): Whose side the review takes at first.
- **onSubmit** (`(contract: { file: File | null; name: string; link: string | null; side: 'customer' | 'vendor' }) => void`): Called when a contract is sent: the dropped or picked file (null for a link or a source sample), its name, the link, and the side.
- **defaultState** (`{ file?: { name: string; size: string }; link?: string; over?: boolean; refused?: string; phase?: 'reading' | 'done'; clause?: number; reviews?: number }`): Opens it in a given state, held there: a contract attached, a link pasted, a file over the card or turned down, mid-read on a clause, or done. It turns the demo off. For documentation and tests.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer drags a contract in and sends it, then pastes a link.
- **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`): Added to the stage, for sizing and placement.

## Accessibility

- The card is a labelled section; the header’s count, the copy and every state change are plain text, and attachments, refusals, reading and results are announced in a polite live region.
- Dragging is never required: the + button opens a file picker that accepts the same PDF and DOCX files, and the field takes a link from the keyboard; Enter sends.
- Send is a real submit button marked aria-disabled until there is something to read, so it stays focusable and says why nothing happens; while reading its label says so.
- Customer or Vendor is a radiogroup of two radios with a roving tab stop and arrow keys; the attachment’s remove button names the file it removes.
- The drawing is decorative and aria-hidden. Its float, guides and scan line stop under reduced motion, as do the demo and the springy transitions.
- Every control shows a 2 px focus ring; the field shows it on the whole composer.

## Source

### contract-dropzone/contract-dropzone.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useRef,
  useState,
  type DragEvent as ReactDragEvent,
  type FormEvent,
  type KeyboardEvent as ReactKeyboardEvent,
} from 'react';
import { DemoPointer, runDemo, type Point } from './demo-pointer';
import { Illustration } from './illustration';
import { useAutoplay } from './use-autoplay';
import styles from './contract-dropzone.module.css';

/*
 * Contract dropzone: the empty state of a contract-review assistant. Nothing is in review yet;
 * a drawing of a contract with one clause lifted off it says what the assistant does, and the
 * composer under it takes the first contract: a dropped or picked PDF or DOCX, a pasted link, or
 * a file from one of the sources under it.
 *
 * - Drag a file over the card and the whole card is the target: the inset lights up mint, the
 *   strip rises, the copy says to let go. Drop a PDF or DOCX and it is attached to the composer;
 *   anything else is turned down, with the reason. The + button picks a file the same way, for
 *   the keyboard and for touch.
 * - The send button wakes up once there is something to read: a file, or a link in the field.
 *   Sending reads it for a moment (the scan line quickens, the copy counts the clauses), then
 *   says what it found, and the header counts one more contract in review.
 * - Customer or Vendor: whose side the review takes, a radio group with a sliding thumb.
 * - The drawing idles: the strip floats over its clause and a scan line sweeps the sheet. All of
 *   it is transforms, paused off screen and still under reduced motion.
 * - While on screen and untouched, a demo pointer drags a contract in, picks a side, sends it,
 *   then pastes a link. Any input of the reader's hands it over.
 * - defaultState opens it in a given state (a file attached, a link pasted, a file over it or
 *   turned down, mid-read or done), held there, with the demo off: for documentation and tests.
 */

type Side = 'customer' | 'vendor';
type Phase = 'idle' | 'reading' | 'done';
type Source = 'Drive' | 'E-sign' | 'Inbox';
type Doc = { name: string; size: string };

/** Where the card opens (defaultState): for documentation and tests. It turns the demo off. */
export type ContractDropzoneState = {
  /** A contract already attached. */
  file?: Doc;
  /** A link already in the field (when no file is attached). */
  link?: string;
  /** A file held over the card. */
  over?: boolean;
  /** A file just turned down, by its name. */
  refused?: string;
  /** Reading the attached file or the link (held on `clause`), or its result (held). */
  phase?: 'reading' | 'done';
  /** The clause the reading is on, 1 to 18. */
  clause?: number;
  /** How many contracts are in review already. */
  reviews?: number;
};

/** How many clauses the reading counts, and how long it lingers on each. */
const CLAUSES = 18;
const CLAUSE_MS = 130;
/** How long the result stays before the card is ready for the next contract. */
const DONE_MS = 2600;

/** What each source brings in: a sample contract. */
const SOURCES: Record<Source, Doc> = {
  Drive: { name: 'Master services agreement.pdf', size: '412 KB' },
  'E-sign': { name: 'Order form 2291.pdf', size: '186 KB' },
  Inbox: { name: 'Mutual NDA, Halden Labs.docx', size: '58 KB' },
};
/** The contract the demo drags in, and the link it pastes. */
const DEMO_FILE: Doc = { name: 'MSA, Halden Labs.pdf', size: '342 KB' };
const DEMO_LINK = 'https://files.example.com/order-form.pdf';

const ACCEPT =
  '.pdf,.docx,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document';
const READABLE = /\.(pdf|docx)$/i;
const LINK = /^(https?:\/\/)?[\w-]+(\.[\w-]+)+(\/\S*)?$/i;

function sizeOf(bytes: number) {
  if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}

/** A link's name for the copy: its last path segment, or its host. */
function nameOf(link: string) {
  try {
    const url = new URL(/^https?:\/\//i.test(link) ? link : `https://${link}`);
    const last = url.pathname.split('/').filter(Boolean).pop();
    return last ? decodeURIComponent(last) : url.host;
  } catch {
    return link;
  }
}

/* Icons, drawn on a 16-unit grid; the stylesheet sizes them. */
function TrayIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" focusable="false" aria-hidden="true">
      <path d="M2.2 9.2L3.9 3.9C4.1 3.3 4.6 3 5.2 3h5.6c.6 0 1.1.3 1.3.9l1.7 5.3" />
      <path d="M2.2 9.2V11.6C2.2 12.4 2.8 13 3.6 13h8.8c.8 0 1.4-.6 1.4-1.4V9.2H10.4C10 10.4 9.1 11 8 11S6 10.4 5.6 9.2Z" />
    </svg>
  );
}
function FolderIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" focusable="false" aria-hidden="true">
      <path d="M2 4.4C2 3.6 2.6 3 3.4 3H6l1.4 1.6h5.2c.8 0 1.4.6 1.4 1.4v5.6c0 .8-.6 1.4-1.4 1.4H3.4c-.8 0-1.4-.6-1.4-1.4Z" />
      <path d="M2 7h12" />
    </svg>
  );
}
function SignIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" focusable="false" aria-hidden="true">
      <path d="M1.5 12.5C2.6 10.3 3.6 6.4 4.4 4.2c.4-1 1.2-.7 1 .4-.4 2.2-.8 5.4-.3 7.1.3.9 1 .6 1.4-.2l1.3-2.6c.3-.6.9-.5.9.2v1.4c0 .7.6.9 1.1.4l1-1.1c.4-.4.9-.2 1.1.3.2.6.8.7 1.6.4" />
    </svg>
  );
}
function DocIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" focusable="false" aria-hidden="true">
      <path d="M4.4 1.8h4.7l3.3 3.3v7.7c0 .8-.6 1.4-1.4 1.4H4.4c-.8 0-1.4-.6-1.4-1.4V3.2c0-.8.6-1.4 1.4-1.4Z" />
      <path d="M9 1.9V5.2h3.3M5.6 8.4h4.8M5.6 10.8h3.4" />
    </svg>
  );
}

const SOURCE_ICONS: Record<Source, typeof TrayIcon> = {
  Drive: FolderIcon,
  'E-sign': SignIcon,
  Inbox: TrayIcon,
};

export function ContractDropzone({
  product = 'Vellum',
  playbook = 'Playbook v3',
  defaultSide = 'customer',
  onSubmit,
  defaultState,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The assistant's name, in the copy under the drawing. */
  product?: string;
  /** The playbook the review checks against, on the header's pill. */
  playbook?: string;
  /** Whose side the review takes at first. */
  defaultSide?: Side;
  /** Called when a contract is sent: the file (null for a link or a sample), the link, the side. */
  onSubmit?: (contract: {
    file: File | null;
    name: string;
    link: string | null;
    side: Side;
  }) => void;
  /** Opens it in a given state, held there, with the demo off: for documentation and tests. */
  defaultState?: ContractDropzoneState;
  /** While on screen and untouched, a demo pointer drags a contract in and sends it. */
  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 } = useAutoplay<HTMLDivElement>(6000);
  const start = defaultState;
  /** A phase given by defaultState is held: it neither counts on nor times out. */
  const held = useRef(!!start?.phase);
  const [phase, setPhase] = useState<Phase>(start?.phase ?? 'idle');
  const [doc, setDoc] = useState<Doc | null>(
    start?.phase === 'done' ? null : (start?.file ?? null),
  );
  const [text, setText] = useState(
    start?.phase === 'done' || start?.file ? '' : (start?.link ?? ''),
  );
  const [side, setSide] = useState<Side>(defaultSide);
  const [over, setOver] = useState(start?.over ?? false);
  const [demoOver, setDemoOver] = useState(false);
  const [refused, setRefused] = useState<string | null>(start?.refused ?? null);
  const [reviews, setReviews] = useState(start?.reviews ?? 0);
  const [clause, setClause] = useState(start?.clause ?? 0);
  const [reading, setReading] = useState(() =>
    start?.phase ? (start.file?.name ?? nameOf(start.link ?? '')) : '',
  );
  const [message, setMessage] = useState('');
  const [pressed, setPressed] = useState<string | null>(null);

  const inset = useRef<HTMLDivElement>(null);
  const field = useRef<HTMLInputElement>(null);
  const picker = useRef<HTMLInputElement>(null);
  const sendButton = useRef<HTMLButtonElement>(null);
  const sideButtons = useRef<Record<Side, HTMLButtonElement | null>>({
    customer: null,
    vendor: null,
  });
  const pointer = useRef<HTMLSpanElement>(null);
  const ghost = useRef<HTMLDivElement>(null);
  /** The real file behind the attachment, when the reader gave one. */
  const file = useRef<File | null>(null);
  /** Drag events fire on every child the file crosses: entered minus left says if it is over. */
  const depth = useRef(0);
  const refuseTimer = useRef(0);

  // The drawing's idle motion stops while the card is off screen or its tab is hidden.
  const [paused, setPaused] = useState(false);
  useEffect(() => {
    const element = root.current;
    if (!element) return;
    let onScreen = true;
    const update = () => setPaused(!onScreen || document.hidden);
    const observer = new IntersectionObserver(([entry]) => {
      onScreen = entry.isIntersecting;
      update();
    });
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
    };
  }, [root]);

  const busy = phase !== 'idle';
  const link = !doc && LINK.test(text.trim()) ? text.trim() : null;
  const ready = !busy && (doc !== null || link !== null);
  const dragging = over || demoOver;

  /** Repeats are made to differ, so screen readers announce them again. */
  const announce = (words: string) => setMessage((now) => (now === words ? `${words} ` : words));

  const attach = (next: Doc, real: File | null = null) => {
    file.current = real;
    setDoc(next);
    setText('');
    setRefused(null);
    announce(`${next.name} attached.`);
  };

  const refuse = (name: string) => {
    setRefused(name);
    announce(`${name} can't be read: drop a PDF or DOCX.`);
    window.clearTimeout(refuseTimer.current);
    refuseTimer.current = window.setTimeout(() => setRefused(null), 2800);
  };

  const receive = (real: File | undefined) => {
    if (!real || busy) return;
    if (!READABLE.test(real.name)) return refuse(real.name);
    attach({ name: real.name, size: sizeOf(real.size) }, real);
  };

  const remove = () => {
    file.current = null;
    setDoc(null);
    announce('Attachment removed.');
    field.current?.focus();
  };

  const send = () => {
    if (!ready) return;
    const name = doc?.name ?? nameOf(link ?? '');
    onSubmit?.({ file: file.current, name, link, side });
    setReading(name);
    setClause(0);
    setPhase('reading');
    announce(`Reading ${name}.`);
  };

  // Reading: the clauses tick by, then the result holds a moment and the card is ready again.
  useEffect(() => {
    if (held.current) return;
    if (phase === 'reading') {
      let count = 0;
      const timer = window.setInterval(() => {
        count += 1;
        setClause(count);
        if (count < CLAUSES) return;
        window.clearInterval(timer);
        file.current = null;
        setDoc(null);
        setText('');
        setReviews((now) => now + 1);
        setPhase('done');
      }, CLAUSE_MS);
      return () => window.clearInterval(timer);
    }
    if (phase === 'done') {
      const timer = window.setTimeout(() => setPhase('idle'), DONE_MS);
      return () => window.clearTimeout(timer);
    }
  }, [phase]);
  const announceDone = useEffectEvent(() =>
    announce(`3 redlines drafted for ${reading}. ${reviews} in review.`),
  );
  useEffect(() => {
    if (phase === 'done' && !held.current) announceDone();
  }, [phase]);

  useEffect(() => () => window.clearTimeout(refuseTimer.current), []);

  /* Dragging a file over the card. Only files make it a target; text dragged around does not. */
  const hasFiles = (event: ReactDragEvent) => event.dataTransfer.types.includes('Files');
  const onDragEnter = (event: ReactDragEvent) => {
    if (!hasFiles(event)) return;
    event.preventDefault();
    take();
    depth.current += 1;
    if (!busy) setOver(true);
  };
  const onDragOver = (event: ReactDragEvent) => {
    if (!hasFiles(event)) return;
    event.preventDefault();
    event.dataTransfer.dropEffect = busy ? 'none' : 'copy';
  };
  const onDragLeave = (event: ReactDragEvent) => {
    if (!hasFiles(event)) return;
    depth.current = Math.max(0, depth.current - 1);
    if (!depth.current) setOver(false);
  };
  const onDrop = (event: ReactDragEvent) => {
    if (!hasFiles(event)) return;
    event.preventDefault();
    depth.current = 0;
    setOver(false);
    receive(event.dataTransfer.files[0]);
  };

  const onSubmitForm = (event: FormEvent) => {
    event.preventDefault();
    send();
  };

  /** The side switch is a radio group: arrows move along it. */
  const onSideKey = (event: ReactKeyboardEvent) => {
    if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;
    event.preventDefault();
    const next: Side = side === 'customer' ? 'vendor' : 'customer';
    setSide(next);
    sideButtons.current[next]?.focus();
  };

  const fromSource = (source: Source) => {
    if (busy) return;
    attach(SOURCES[source]);
    announce(`${SOURCES[source].name} attached from ${source}.`);
  };

  // The demo: a contract is dragged in and sent, then a link is pasted and sent.
  const demoStep = useEffectEvent(
    (
      step:
        | 'reset'
        | 'over'
        | 'drop'
        | 'vendor'
        | 'customer'
        | 'send'
        | 'clear'
        | { press: string }
        | { text: string },
    ) => {
      if (step === 'reset') {
        setReviews(0);
        setDoc((now) => (now === DEMO_FILE ? null : now));
        setText((now) => (DEMO_LINK.startsWith(now) ? '' : now));
      } else if (step === 'over') setDemoOver(true);
      else if (step === 'drop') {
        setDemoOver(false);
        attach(DEMO_FILE);
      } else if (step === 'vendor' || step === 'customer') setSide(step);
      else if (step === 'send') send();
      else if (step === 'clear') setPressed(null);
      else if ('press' in step) setPressed(step.press);
      else setText(step.text);
    },
  );
  const demo = autoplay && playing && !defaultState;
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    const box = ghost.current;
    const frame = inset.current;
    if (!demo || !stage || !arrow || !box || !frame) return;
    let dragged: Animation | null = null;

    /** The middle of an element, in the stage's own px. */
    const middle = (element: Element | null | undefined, dx = 0, dy = 0): Point => {
      const area = stage.getBoundingClientRect();
      const scale = area.width / stage.offsetWidth || 1;
      const rect = element?.getBoundingClientRect() ?? area;
      return {
        x: (rect.left + rect.width / 2 - area.left) / scale + dx,
        y: (rect.top + rect.height / 2 - area.top) / scale + dy,
      };
    };
    const place = ({ x, y }: Point) => `translate(${x + 6}px, ${y + 10}px)`;

    const stop = runDemo(stage, arrow, async ({ wait, glide, glyph }) => {
      const press = async (name: string, target: Element | null | undefined, then: () => void) => {
        await glide(middle(target), 640);
        glyph('hand');
        await wait(260);
        demoStep({ press: name });
        await wait(150);
        demoStep('clear');
        then();
        await wait(420);
        glyph('arrow');
      };
      for (;;) {
        demoStep('reset');
        await wait(1100);
        // Pick the contract up at the lower right, and carry it over the card.
        const start = { x: stage.offsetWidth * 0.86, y: stage.offsetHeight * 0.8 };
        await glide(start, 500);
        box.style.transform = place(start);
        box.dataset.shown = 'true';
        await wait(300);
        const carry = (to: Point, ms: number) =>
          glide(to, ms, ({ points, timing }) => {
            dragged?.cancel();
            box.style.transform = place(to);
            dragged = box.animate(
              points.map((point) => ({ transform: place(point) })),
              timing,
            );
          });
        await carry(middle(frame, 60, 70), 900);
        demoStep('over');
        await carry(middle(frame, 10, 10), 700);
        await wait(700);
        box.dataset.shown = 'false';
        demoStep('drop');
        await wait(900);
        await press('vendor', sideButtons.current.vendor, () => demoStep('vendor'));
        await press('send', sendButton.current, () => demoStep('send'));
        await wait(CLAUSES * CLAUSE_MS + DONE_MS + 400);
        // Then paste a link, and send it too.
        // (The field is never focused for real: the demo must not take the page's focus.)
        await press('field', field.current, () => undefined);
        for (let i = 4; i <= DEMO_LINK.length; i += 3) {
          demoStep({ text: DEMO_LINK.slice(0, i) });
          await wait(45);
        }
        demoStep({ text: DEMO_LINK });
        await wait(600);
        await press('customer', sideButtons.current.customer, () => demoStep('customer'));
        await press('send', sendButton.current, () => demoStep('send'));
        await wait(CLAUSES * CLAUSE_MS + DONE_MS + 900);
      }
    });
    return () => {
      stop();
      dragged?.cancel();
      box.dataset.shown = 'false';
      setDemoOver(false);
      setPressed(null);
      // What the demo left behind goes: the reader starts from a clean composer.
      setDoc((now) => (now === DEMO_FILE ? null : now));
      setText((now) => (now && DEMO_LINK.startsWith(now) ? '' : now));
    };
  }, [demo, root]);

  const state = refused ? 'refused' : dragging ? 'over' : ready ? 'ready' : phase;
  const copy = {
    idle: {
      title: 'Drop a contract to start',
      body: `${product} reads every clause, checks it against your playbook and drafts the redlines.`,
    },
    ready: {
      title: 'Ready when you are',
      body: `${doc?.name ?? nameOf(link ?? '')} will be read from the ${side}’s side and checked against ${playbook}.`,
    },
    over: {
      title: 'Let go to start a review',
      body: `PDF or DOCX, reviewed from the ${side}’s side against ${playbook}.`,
    },
    refused: {
      title: 'That file can’t be read',
      body: `${refused ?? ''} isn’t a PDF or a DOCX. Drop one of those, or paste a link to it.`,
    },
    reading: {
      title: `Reading ${reading}…`,
      body: `Clause ${Math.max(1, clause)} of ${CLAUSES} checked against ${playbook}.`,
    },
    done: {
      title: '3 redlines drafted',
      body: `${reading} is in review: 15 clauses match your playbook, 3 need changes.`,
    },
  }[state];

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="stage"
      data-color-scheme={colorScheme}
      data-paused={paused || undefined}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
    >
      <section
        className={styles.card}
        data-part="card"
        data-state={state}
        aria-label="Reviews"
        onDragEnter={onDragEnter}
        onDragOver={onDragOver}
        onDragLeave={onDragLeave}
        onDrop={onDrop}
      >
        <header className={styles.header} data-part="header">
          <span className={styles.badge} aria-hidden="true">
            <TrayIcon className={styles.badgeIcon} />
          </span>
          <span className={styles.heading}>
            <span className={styles.title}>Reviews</span>
            <span className={styles.subtitle} data-part="count">
              {reviews ? `${reviews} in review` : 'Nothing in review yet'}
            </span>
          </span>
          <span className={styles.playbook} data-part="playbook">
            {playbook}
          </span>
        </header>

        <div ref={inset} className={styles.inset} data-part="illustration" aria-hidden="true">
          <Illustration />
        </div>

        <div className={styles.copy} data-part="copy">
          <p className={styles.copyTitle}>{copy.title}</p>
          <p className={styles.copyBody}>{copy.body}</p>
        </div>

        <form className={styles.composer} data-part="composer" onSubmit={onSubmitForm}>
          <div className={styles.composerInner}>
            <div className={styles.entry}>
              {doc ? (
                <span className={styles.attachment} data-part="attachment">
                  <DocIcon className={styles.attachmentIcon} />
                  <span className={styles.attachmentName}>{doc.name}</span>
                  <span className={styles.attachmentSize}>{doc.size}</span>
                  <button
                    type="button"
                    className={styles.remove}
                    aria-label={`Remove ${doc.name}`}
                    disabled={busy}
                    onClick={remove}
                  >
                    <svg viewBox="0 0 16 16" focusable="false" aria-hidden="true">
                      <path d="M4.5 4.5l7 7M11.5 4.5l-7 7" />
                    </svg>
                  </button>
                </span>
              ) : (
                <input
                  ref={field}
                  className={styles.field}
                  data-part="field"
                  data-pressed={pressed === 'field' || undefined}
                  type="text"
                  inputMode="url"
                  autoComplete="off"
                  spellCheck={false}
                  value={text}
                  disabled={busy}
                  placeholder="Drop a PDF or DOCX, or paste a link"
                  aria-label="Contract link"
                  onChange={(event) => setText(event.target.value)}
                />
              )}
            </div>
            <div className={styles.controls}>
              <button
                type="button"
                className={styles.plus}
                data-part="attach"
                aria-label="Attach a PDF or DOCX"
                disabled={busy}
                onClick={() => picker.current?.click()}
              >
                <svg viewBox="0 0 16 16" focusable="false" aria-hidden="true">
                  <path d="M8 2.5v11M2.5 8h11" />
                </svg>
              </button>
              <input
                ref={picker}
                className={styles.hidden}
                type="file"
                accept={ACCEPT}
                tabIndex={-1}
                aria-hidden="true"
                onChange={(event) => {
                  receive(event.target.files?.[0]);
                  event.target.value = '';
                }}
              />
              <div
                className={styles.sides}
                data-part="side"
                data-side={side}
                role="radiogroup"
                aria-label="Review from the side of"
                onKeyDown={onSideKey}
              >
                <span className={styles.thumb} data-part="thumb" aria-hidden="true" />
                {(['customer', 'vendor'] as const).map((value) => (
                  <button
                    key={value}
                    ref={(element) => {
                      sideButtons.current[value] = element;
                    }}
                    type="button"
                    role="radio"
                    aria-checked={side === value}
                    tabIndex={side === value ? 0 : -1}
                    className={styles.side}
                    data-pressed={pressed === value || undefined}
                    onClick={() => setSide(value)}
                  >
                    {value === 'customer' ? 'Customer' : 'Vendor'}
                  </button>
                ))}
              </div>
              <button
                ref={sendButton}
                type="submit"
                className={styles.send}
                data-part="send"
                data-pressed={pressed === 'send' || undefined}
                data-busy={phase === 'reading' || undefined}
                aria-label={phase === 'reading' ? 'Reading the contract' : 'Start the review'}
                aria-disabled={!ready || undefined}
              >
                <svg
                  className={styles.arrow}
                  viewBox="0 0 16 16"
                  focusable="false"
                  aria-hidden="true"
                >
                  <path d="M8 13V3M3.6 7.4L8 3l4.4 4.4" />
                </svg>
                <span className={styles.spinner} aria-hidden="true" />
              </button>
            </div>
          </div>
        </form>

        <div className={styles.sources} data-part="sources" role="group" aria-label="Import from">
          {(Object.keys(SOURCES) as Source[]).map((source) => {
            const Icon = SOURCE_ICONS[source];
            return (
              <button
                key={source}
                type="button"
                className={styles.source}
                data-part="source"
                disabled={busy}
                onClick={() => fromSource(source)}
              >
                <Icon className={styles.sourceIcon} />
                {source}
              </button>
            );
          })}
        </div>
      </section>

      <div ref={ghost} className={styles.ghost} data-shown="false" aria-hidden="true">
        <DocIcon className={styles.ghostIcon} />
        <span>{DEMO_FILE.name}</span>
      </div>
      <div className={styles.pointerLayer} aria-hidden="true">
        <DemoPointer
          ref={pointer}
          className={styles.pointer}
          arrowClass={styles.arrowGlyph}
          handClass={styles.hand}
        />
      </div>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### contract-dropzone/illustration.tsx

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

import { useId, type CSSProperties } from 'react';
import styles from './contract-dropzone.module.css';

/*
 * The empty state's drawing, in isometric linework: a stack of three sheets, the top one curling
 * at its corners, with one clause lifted off it as a mint strip; a pen and a desk stamp beside it.
 *
 * It is drawn in the inset's own px (480 × 223), so every point here is where it lands on a card
 * at its full width; the SVG scales as a whole below that. The lines of text on the top sheet are
 * laid out on the sheet itself (a bilinear map of its four corners), so they follow its slant.
 *
 * What moves (all by transforms, in the stylesheet): the strip floats over its clause and its
 * dotted guides stretch with it; a scan line sweeps the sheet now and then, quicker while a
 * contract is being read. All of it is decorative (aria-hidden on the inset).
 */

type P = [number, number];

/** The top sheet's corners, as drawn (before its corners curl). */
const TL: P = [236.4, 39.9];
const TR: P = [332.5, 69.4];
const BL: P = [117.5, 135];
const BR: P = [218.75, 166];

/** A point on the top sheet: u across it (0 left, 1 right), v down it (0 top, 1 bottom). */
function on(u: number, v: number): P {
  const top: P = [TL[0] + (TR[0] - TL[0]) * u, TL[1] + (TR[1] - TL[1]) * u];
  const bottom: P = [BL[0] + (BR[0] - BL[0]) * u, BL[1] + (BR[1] - BL[1]) * u];
  return [top[0] + (bottom[0] - top[0]) * v, top[1] + (bottom[1] - top[1]) * v];
}

const f = (n: number) => Math.round(n * 100) / 100;
const line = (a: P, b: P) => `M${f(a[0])} ${f(a[1])}L${f(b[0])} ${f(b[1])}`;

/** The lines of text on the top sheet: [v, from u, to u]. */
const TEXT: [number, number, number][] = [
  [0.12, 0.52, 0.94],
  [0.18, 0.44, 0.92],
  [0.24, 0.36, 0.95],
  [0.3, 0.28, 0.86],
  [0.36, 0.22, 0.92],
  [0.42, 0.2, 0.9],
  [0.48, 0.17, 0.95],
  [0.54, 0.15, 0.84],
  [0.6, 0.13, 0.9],
  [0.66, 0.12, 0.72],
  [0.72, 0.12, 0.82],
];
const TEXT_PATH = TEXT.map(([v, a, b]) => line(on(a, v), on(b, v))).join('');

/** The clause the strip was lifted from: a box drawn round some of the sheet's lines. */
const CLAUSE: P[] = [
  [185, 90],
  [288.75, 122],
  [270, 137.5],
  [166.25, 105],
];
const CLAUSE_PATH = `M${CLAUSE.map((p) => p.map(f).join(' ')).join('L')}Z`;
/** A closed path through `points` with its corners rounded by `r`. */
function rounded(points: P[], r: number) {
  const n = points.length;
  let d = '';
  for (let i = 0; i < n; i += 1) {
    const prev = points[(i + n - 1) % n];
    const at = points[i];
    const next = points[(i + 1) % n];
    const toPrev = Math.hypot(prev[0] - at[0], prev[1] - at[1]);
    const toNext = Math.hypot(next[0] - at[0], next[1] - at[1]);
    const a: P = [
      at[0] + ((prev[0] - at[0]) / toPrev) * r,
      at[1] + ((prev[1] - at[1]) / toPrev) * r,
    ];
    const b: P = [
      at[0] + ((next[0] - at[0]) / toNext) * r,
      at[1] + ((next[1] - at[1]) / toNext) * r,
    ];
    d += `${i ? 'L' : 'M'}${f(a[0])} ${f(a[1])}Q${f(at[0])} ${f(at[1])} ${f(b[0])} ${f(b[1])}`;
  }
  return `${d}Z`;
}

/** The strip: its top face, and how thick it is. */
const FACE: P[] = [
  [184, 40.6],
  [287.5, 71.9],
  [271, 85],
  [168, 53],
];
const THICK = 4.4;
const down = ([x, y]: P): P => [x, y + THICK];
/** Its outline: the top face and the two sides that show, below its front and right edges. */
const STRIP_OUTLINE = rounded(
  [FACE[0], FACE[1], down(FACE[1]), down(FACE[2]), down(FACE[3]), FACE[3]],
  3.6,
);
/** The top face's front and right edges, over the sides. */
const STRIP_EDGE = `M${f(FACE[3][0] + 1.6)} ${f(FACE[3][1] + 1.6)}L${f(FACE[2][0] - 3)} ${f(FACE[2][1] + 0.4)}Q${FACE[2][0]} ${FACE[2][1]} ${f(FACE[2][0] + 2.4)} ${f(FACE[2][1] - 1.9)}L${f(FACE[1][0] - 1.3)} ${f(FACE[1][1] + 1.3)}`;

export function Illustration() {
  // Ids of its own, so that several cards on a page never share a clip or a gradient.
  const id = useId().replace(/[^\w-]/g, '');
  return (
    <svg className={styles.art} viewBox="0 0 480 223" focusable="false" aria-hidden="true">
      <defs>
        <clipPath id={`${id}-sheet`}>
          <path d="M117.5 135L218.75 166L250.6 163.1L320 108.75C330 96 334 84 332.5 69.4L220 34.4L200 68.75Z" />
        </clipPath>
        <linearGradient id={`${id}-scan`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="var(--mint)" stopOpacity="0" />
          <stop offset="1" stopColor="var(--mint)" stopOpacity="0.16" />
        </linearGradient>
      </defs>

      {/* The sheets, bottom to top. */}
      <g className={styles.sheets} data-part="sheets">
        <path className={styles.sheetBack} d="M85 140L206.9 178.1L300 104L180 63Z" />
        <path
          className={styles.sheetMiddle}
          d="M101.25 137.5L223 175.6L236 164.4L300 110L190 66.25Z"
        />
        <path
          className={styles.sheetFront}
          d="M117.5 135L218.75 166L231 156L250.6 163.1L260 156.25L320 108.75C330 96 334 84 332.5 69.4L220 34.4L210.6 31.9Q209.2 39 210.6 46L200 68.75Z"
        />
        {/* The corner folded back, at the bottom right. */}
        <path className={styles.fold} d="M218.75 166L250.6 163.1" />
        <path className={styles.text} d={TEXT_PATH} />
        <path className={styles.clause} d={CLAUSE_PATH} />
        {/* The signature: a line, and a scribble over it. */}
        <path className={styles.signLine} d="M139.4 132.1L175 142.25" />
        <path
          className={styles.scribble}
          d="M143.5 128.6L148.5 127.4L151.2 131.3L154.6 128.9L157.6 133.4L159.6 131.6L163.4 135.6L166.2 133.9L172.4 136.2"
        />
      </g>

      {/* The scan line, inside the top sheet. */}
      <g clipPath={`url(#${id}-sheet)`}>
        <g className={styles.scan} data-part="scan">
          <path d="M236.4 29.9L332.5 59.4L332.5 69.4L236.4 39.9Z" fill={`url(#${id}-scan)`} />
          <path className={styles.scanLine} d="M236.4 39.9L332.5 69.4" />
        </g>
      </g>

      {/* A paperclip over the sheets' edge. */}
      <path
        className={styles.clip}
        transform="translate(210.5 155) rotate(-38)"
        d="M-8 -1.5L13 -1.5A2 2 0 0 1 13 2.5L-15 2.5A3.5 3.5 0 0 1 -15 -4.5L15 -4.5A4.5 4.5 0 0 1 15 4.5L2 4.5"
      />

      {/* The stamp: its base, its neck, its knob. */}
      <g className={styles.stamp} data-part="stamp">
        <path
          className={styles.stampBase}
          d="M287.5 192.5L287.5 202.5A23.75 10.6 0 0 0 335 202.5L335 192.5"
        />
        <path className={styles.stampBase} d="M287.5 198.8A23.75 10.6 0 0 0 335 198.8" />
        <ellipse className={styles.stampBase} cx="311.25" cy="192.5" rx="23.75" ry="10.6" />
        <path
          className={styles.stampHandle}
          d="M304.2 176.6C303.2 182 301.3 187.4 300.8 191.6A10.6 3.6 0 0 0 321.8 191.6C321.2 187.4 319.3 182 318.3 176.6Z"
        />
        <path className={styles.stampFaint} d="M302.6 186.6A8.8 2.6 0 0 0 320 186.6" />
        <circle className={styles.stampHandle} cx="311.25" cy="165" r="13.6" />
        <path className={styles.stampFaint} d="M300.4 173.2A11.2 3.3 0 0 0 322 173.2" />
      </g>

      {/* The pen, drawn along its own axis and laid down at an angle. */}
      <g className={styles.pen} data-part="pen" transform="translate(307.5 121.9) rotate(39.3)">
        <path
          className={styles.penBody}
          d="M0 0L12 -6.1L103.5 -6.1A6.1 6.1 0 0 1 103.5 6.1L12 6.1Z"
        />
        <path
          className={styles.penRing}
          d="M12 -6.1A3.5 6.1 0 0 0 12 6.1M30 -6.1A3.5 6.1 0 0 0 30 6.1M79 -6.1A3.5 6.1 0 0 0 79 6.1M0 0L4.6 -0.3"
        />
        <path className={styles.penFaint} d="M16 -2L28 -2M34 -1.8L72 -1.8" />
        <path className={styles.penRing} d="M79.5 2L98 2Q100.6 2 100.6 4Q100.6 6.1 98 6.1" />
      </g>

      {/* The dotted guides, from the strip down to its clause; they stretch as it floats. */}
      <g data-part="guides">
        <g className={styles.guideLift} style={{ '--length': 43.75 } as CSSProperties}>
          <path className={styles.guide} d="M168.75 61.25L168.75 105" />
        </g>
        <g className={styles.guideLift} style={{ '--length': 47.5 } as CSSProperties}>
          <path className={styles.guide} d="M270 89.4L270 136.9" />
        </g>
      </g>

      {/* The clause, lifted off the sheet: the mint strip with its lines and a check. */}
      <g className={styles.lift}>
        <g className={styles.strip} data-part="strip">
          <path className={styles.stripBody} d={STRIP_OUTLINE} />
          <path className={styles.stripEdge} d={STRIP_EDGE} />
          <path
            className={styles.stripText}
            d="M187.5 45.9L255.6 67.1M183.75 49L245.6 68.4M180 52.5L231.25 68.1"
          />
          <path className={styles.check} d="M258.75 71.9L260.6 76.25L269.4 74.4" />
        </g>
      </g>
    </svg>
  );
}
```

### contract-dropzone/contract-dropzone.module.css

```css
/* Contract dropzone (contract-dropzone.tsx): a card on a quiet stage, 502px wide at most. Its
   sizes are px of the card at that width; under it, the card takes the stage's width and the
   drawing scales as a whole (it keeps its proportions).

   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: the strip's float, the guides' stretch and the scan line are transforms and opacity on
   a few SVG groups, paused when the stage is off screen (the component sets data-paused) and
   still under reduced motion. */

.root {
  /* The stage, the card, and the inset the drawing sits in. */
  --stage: #efefed;
  --card: #fff;
  --card-ring: rgb(0 0 0 / 0.07);
  --card-shadow: rgb(0 0 0 / 0.05);
  --inset: #f7f7f6;
  --inset-edge: #ebebe9;
  /* Words: strong, quiet, and the placeholder. */
  --ink: #161616;
  --muted: #76767a;
  --faint: #a3a3a5;
  /* The header's tray badge and playbook pill. */
  --badge: #f0f0ee;
  --badge-ink: #3a3a3a;
  --pill: #f1f1ef;
  /* The composer: its outer tray, the field inside it, and the round buttons. */
  --composer: #f5f5f4;
  --composer-edge: #e8e8e6;
  --field-top: #fff;
  --field-bottom: #fbfbfa;
  --field-edge: rgb(0 0 0 / 0.08);
  --button: #efefed;
  --button-ink: #161616;
  --button-hi: rgb(255 255 255 / 0.9);
  --track: #ececea;
  --thumb: #fff;
  --thumb-shadow: rgb(0 0 0 / 0.1);
  --send-off: #d6d6d4;
  --send-off-ink: #fff;
  --send-on: #161616;
  --send-on-ink: #fff;
  /* The source chips under the composer. */
  --chip: #f5f5f4;
  --chip-edge: #e5e5e3;
  /* The drawing: the mint clause, the sheets, the pen and the stamp. */
  --mint: #98fec5;
  --mint-edge: #0b8458;
  --mint-glow: rgb(46 214 136 / 0.16);
  --sheet-back: #e3e3e1;
  --sheet-middle: #efefee;
  --sheet-edge: #a6a6a4;
  --text-line: #d6d6d4;
  --clause-edge: #cbcbc9;
  --pen: #a8a8a6;
  --stamp: #8e8e8c;
  --stamp-handle: #a8a8a6;
  --guide: #9a9a98;
  /* A file turned down. */
  --danger: #d9434b;
  /* 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);
  position: relative;
  display: grid;
  place-items: center;
  width: 100%;
  min-height: 520px;
  padding: 40px 16px;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--stage);
  color: var(--ink);
  font-family: var(--sans, Satoshi), ui-sans-serif, system-ui, sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --stage: light-dark(#efefed, #1e1e1e);
    --card: light-dark(#fff, #111);
    --card-ring: light-dark(rgb(0 0 0 / 0.07), rgb(255 255 255 / 0.05));
    --card-shadow: light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0));
    --inset: light-dark(#f7f7f6, #0e0e0e);
    --inset-edge: light-dark(#ebebe9, #1c1c1c);
    --ink: light-dark(#161616, #f2f2f2);
    --muted: light-dark(#76767a, #8f8f8f);
    --faint: light-dark(#a3a3a5, #5c5c5c);
    --badge: light-dark(#f0f0ee, #2d2d2d);
    --badge-ink: light-dark(#3a3a3a, #c6c6c6);
    --pill: light-dark(#f1f1ef, #1e1e1e);
    --composer: light-dark(#f5f5f4, #1a1a1a);
    --composer-edge: light-dark(#e8e8e6, #282828);
    --field-top: light-dark(#fff, #232323);
    --field-bottom: light-dark(#fbfbfa, #323232);
    --field-edge: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.075));
    --button: light-dark(#efefed, #363636);
    --button-ink: light-dark(#161616, #f2f2f2);
    --button-hi: light-dark(rgb(255 255 255 / 0.9), rgb(255 255 255 / 0.07));
    --track: light-dark(#ececea, #171717);
    --thumb: light-dark(#fff, #363636);
    --thumb-shadow: light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.25));
    --send-off: light-dark(#d6d6d4, #686868);
    --send-off-ink: light-dark(#fff, #1f1f1f);
    --send-on: light-dark(#161616, #f2f2f2);
    --send-on-ink: light-dark(#fff, #111);
    --chip: light-dark(#f5f5f4, #262626);
    --chip-edge: light-dark(#e5e5e3, #313131);
    --mint-edge: light-dark(#0b8458, #007a52);
    --mint-glow: light-dark(rgb(46 214 136 / 0.16), rgb(152 254 197 / 0.09));
    --sheet-back: light-dark(#e3e3e1, #303030);
    --sheet-middle: light-dark(#efefee, #1a1a1a);
    --sheet-edge: light-dark(#a6a6a4, #4c4c4c);
    --text-line: light-dark(#d6d6d4, #2e2e2e);
    --clause-edge: light-dark(#cbcbc9, #353535);
    --pen: light-dark(#a8a8a6, #474747);
    --stamp: light-dark(#8e8e8c, #6a6a6a);
    --stamp-handle: light-dark(#a8a8a6, #4e4e4e);
    --guide: light-dark(#9a9a98, #555);
    --danger: light-dark(#d9434b, #ff7a7f);
  }
}
/* 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 p {
  margin: 0;
}
.root button {
  font-family: inherit;
  -webkit-tap-highlight-color: transparent;
}

/* ——— The card ——— */
.card {
  width: min(100%, 502px);
  padding: 11px;
  border-radius: 20px;
  background: var(--card);
  box-shadow:
    0 0 0 0.5px var(--card-ring),
    0 8px 24px var(--card-shadow);
}

.header {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 1px 0 12.5px;
}
.badge {
  display: grid;
  flex: none;
  place-items: center;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  background: var(--badge);
}
.badgeIcon {
  width: 14px;
  height: 14px;
  fill: none;
  stroke: var(--badge-ink);
  stroke-width: 1.4;
  stroke-linejoin: round;
}
.heading {
  display: grid;
  position: relative;
  top: 1px;
  flex: 1;
  min-width: 0;
}
.title {
  font-size: 14px;
  line-height: 18px;
  font-weight: 500;
  letter-spacing: 0.025em;
}
.subtitle {
  overflow: hidden;
  color: var(--muted);
  font-size: 12px;
  line-height: 17px;
  font-weight: 500;
  letter-spacing: 0.016em;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.playbook {
  flex: none;
  padding: 0 9px;
  border-radius: 10px;
  background: var(--pill);
  color: var(--muted);
  font-size: 12px;
  line-height: 20px;
  font-weight: 500;
  letter-spacing: 0.02em;
}

/* The inset, with the drawing in it. A file over the card lights it up. */
.inset {
  position: relative;
  aspect-ratio: 480 / 223;
  overflow: hidden;
  border: 1px solid var(--inset-edge);
  border-radius: 12px;
  background: var(--inset);
  transition:
    border-color 0.25s ease,
    box-shadow 0.25s ease;
}
.inset::after {
  content: '';
  position: absolute;
  inset: 0;
  background: radial-gradient(60% 70% at 50% 45%, var(--mint-glow), transparent 70%);
  opacity: 0;
  transition: opacity 0.3s ease;
  pointer-events: none;
}
.card[data-state='over'] .inset {
  border-color: color-mix(in srgb, var(--mint) 55%, transparent);
  box-shadow: 0 0 0 3px var(--mint-glow);
}
.card[data-state='over'] .inset::after,
.card[data-state='reading'] .inset::after {
  opacity: 1;
}
.card[data-state='refused'] .inset {
  border-color: color-mix(in srgb, var(--danger) 45%, transparent);
}
.art {
  position: absolute;
  inset: -1px;
  width: calc(100% + 2px);
  height: calc(100% + 2px);
  overflow: visible;
}

/* The drawing's lines. */
.sheets path {
  stroke-linejoin: round;
  stroke-linecap: round;
}
.sheetBack {
  fill: var(--sheet-back);
  stroke: var(--sheet-edge);
  stroke-width: 1.25;
}
.sheetMiddle {
  fill: var(--sheet-middle);
  stroke: var(--sheet-edge);
  stroke-width: 1.25;
}
.sheetFront {
  fill: var(--inset);
  stroke: var(--sheet-edge);
  stroke-width: 1.25;
}
.fold {
  fill: none;
  stroke: var(--sheet-edge);
  stroke-width: 1.1;
}
.text {
  fill: none;
  stroke: var(--text-line);
  stroke-width: 1;
}
.clause {
  fill: none;
  stroke: var(--clause-edge);
  stroke-width: 1;
}
.signLine {
  fill: none;
  stroke: var(--sheet-edge);
  stroke-width: 1;
}
.scribble {
  fill: none;
  stroke: var(--clause-edge);
  stroke-width: 1;
}
.clip {
  fill: none;
  stroke: var(--sheet-edge);
  stroke-width: 1.1;
  stroke-linecap: round;
}
.stamp path,
.stamp ellipse,
.stamp circle {
  stroke-linecap: round;
}
.stampBase {
  fill: var(--inset);
  stroke: var(--stamp);
  stroke-width: 1.15;
}
.stamp path.stampBase:not(:first-child) {
  fill: none;
}
.stampHandle {
  fill: var(--inset);
  stroke: var(--stamp-handle);
  stroke-width: 1.1;
}
.stampFaint {
  fill: none;
  stroke: var(--text-line);
  stroke-width: 1;
}
.penBody {
  fill: var(--inset);
  stroke: var(--pen);
  stroke-width: 1.1;
  stroke-linejoin: round;
}
.penRing {
  fill: none;
  stroke: var(--pen);
  stroke-width: 1;
}
.penFaint {
  fill: none;
  stroke: var(--text-line);
  stroke-width: 1;
}
.guide {
  fill: none;
  stroke: var(--guide);
  stroke-width: 1;
  stroke-dasharray: 2.2 2.2;
}
.stripBody {
  fill: var(--mint);
  stroke: var(--mint-edge);
  stroke-width: 1.3;
  stroke-linejoin: round;
}
.stripEdge,
.stripText,
.check {
  fill: none;
  stroke: var(--mint-edge);
  stroke-width: 1.2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* The scan line: it sweeps the sheet from its top edge to its bottom one. */
.scan {
  opacity: 0;
  animation: scan 5.6s cubic-bezier(0.45, 0, 0.55, 1) 1.2s infinite;
}
.scanLine {
  fill: none;
  stroke: var(--mint);
  stroke-width: 1.2;
  stroke-linecap: round;
}
@keyframes scan {
  0% {
    opacity: 0;
    transform: translate(0, 0);
  }
  6% {
    opacity: 0.9;
  }
  40% {
    opacity: 0.9;
  }
  46%,
  100% {
    opacity: 0;
    transform: translate(-118.9px, 95.1px);
  }
}
.card[data-state='reading'] .scan {
  animation-duration: 1.15s;
  animation-delay: 0s;
  animation-timing-function: linear;
}

/* The strip floats over its clause, and the guides under it stretch along. A file over the card
   lifts it higher (the outer group); the float plays on the inner one. */
.lift,
.guideLift {
  transition: transform 0.45s cubic-bezier(0.3, 1.4, 0.5, 1);
}
.strip {
  animation: float 3.6s ease-in-out infinite alternate;
}
@keyframes float {
  to {
    transform: translateY(-3px);
  }
}
.guideLift,
.guide {
  transform-box: fill-box;
  transform-origin: 50% 100%;
}
.guide {
  animation: stretch 3.6s ease-in-out infinite alternate;
}
@keyframes stretch {
  to {
    transform: scaleY(calc((var(--length) + 3) / var(--length)));
  }
}
.card[data-state='over'] .lift {
  transform: translateY(-9px);
}
.card[data-state='over'] .guideLift {
  transform: scaleY(calc((var(--length) + 9) / var(--length)));
}
.check {
  transform-box: fill-box;
  transform-origin: 30% 80%;
}
.card[data-state='done'] .check {
  animation: tick 0.5s cubic-bezier(0.3, 1.6, 0.5, 1);
}
@keyframes tick {
  from {
    transform: scale(0.4);
    opacity: 0;
  }
}
.root[data-paused] .scan,
.root[data-paused] .strip,
.root[data-paused] .guide {
  animation-play-state: paused;
}

/* ——— The copy under the drawing ——— */
.copy {
  display: grid;
  justify-items: center;
  gap: 4px;
  min-height: 81px;
  padding: 17px 10px 0;
  text-align: center;
}
.copyTitle {
  max-width: 100%;
  overflow: hidden;
  font-size: 14px;
  line-height: 20px;
  font-weight: 500;
  letter-spacing: 0.015em;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.copyBody {
  max-width: 418px;
  color: var(--muted);
  font-size: 14px;
  line-height: 20px;
  font-weight: 500;
  letter-spacing: 0.012em;
}
.card[data-state='refused'] .copyTitle {
  color: var(--danger);
}

/* ——— The composer ——— */
.composer {
  margin: 16px 0 0;
  padding: 3px 3px 4px;
  border: 1px solid var(--composer-edge);
  border-radius: 18px;
  background: var(--composer);
  transition: border-color 0.25s ease;
}
.card[data-state='over'] .composer {
  border-color: color-mix(in srgb, var(--mint) 45%, transparent);
}
.composerInner {
  padding: 13px 11px 10px 15px;
  border: 1px solid var(--field-edge);
  border-radius: 15px;
  background: linear-gradient(var(--field-top), var(--field-bottom));
}
.entry {
  display: flex;
  align-items: center;
  height: 20px;
}
.field {
  width: 100%;
  min-width: 0;
  height: 20px;
  padding: 0;
  border: 0;
  outline: 0;
  background: none;
  color: var(--ink);
  font: inherit;
  font-size: 14px;
  line-height: 20px;
  font-weight: 500;
}
.field::placeholder {
  color: var(--faint);
  font-weight: 400;
  opacity: 1;
}
.field:disabled {
  color: var(--muted);
}
.attachment {
  display: inline-flex;
  min-width: 0;
  align-items: center;
  gap: 6px;
  max-width: 100%;
  height: 24px;
  margin-left: -4px;
  padding: 0 2px 0 6px;
  border-radius: 8px;
  background: var(--button);
  font-size: 13px;
  font-weight: 500;
  animation: arrive 0.35s cubic-bezier(0.3, 1.3, 0.5, 1);
}
@keyframes arrive {
  from {
    opacity: 0;
    transform: translateY(4px) scale(0.96);
  }
}
.attachmentIcon {
  flex: none;
  width: 13px;
  height: 13px;
  fill: none;
  stroke: var(--mint-edge);
  stroke-width: 1.3;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.attachmentName {
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.attachmentSize {
  flex: none;
  color: var(--muted);
  font-size: 12px;
}
.remove {
  display: grid;
  flex: none;
  place-items: center;
  width: 20px;
  height: 20px;
  padding: 0;
  border: 0;
  border-radius: 6px;
  background: none;
  color: var(--muted);
  cursor: pointer;
}
.remove svg {
  width: 12px;
  height: 12px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
}
.remove:hover {
  color: var(--ink);
}

.controls {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 20px;
}
.plus,
.send {
  display: grid;
  flex: none;
  place-items: center;
  width: 32px;
  height: 32px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  cursor: pointer;
  transition:
    transform 0.15s ease,
    background-color 0.2s ease,
    color 0.2s ease;
}
.plus {
  margin-right: auto;
  background: var(--button);
  box-shadow: inset 0 1px 0 var(--button-hi);
  color: var(--button-ink);
}
.plus svg {
  width: 14px;
  height: 14px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
}
.plus:hover:not(:disabled) {
  filter: brightness(1.12);
}
.plus:disabled {
  cursor: default;
  opacity: 0.5;
}

/* Customer or Vendor: two radios on a track, with a thumb that slides to the checked one. */
.sides {
  position: relative;
  display: grid;
  grid-template-columns: 79px 66px;
  height: 32px;
  padding: 3px;
  border-radius: 16px;
  background: var(--track);
}
.thumb {
  position: absolute;
  top: 3px;
  left: 3px;
  width: 79px;
  height: 26px;
  border-radius: 13px;
  background: var(--thumb);
  box-shadow:
    inset 0 1px 0 var(--button-hi),
    0 1px 2px var(--thumb-shadow);
  transition:
    transform 0.32s cubic-bezier(0.3, 1.25, 0.5, 1),
    width 0.32s cubic-bezier(0.3, 1.25, 0.5, 1);
}
.sides[data-side='vendor'] .thumb {
  width: 66px;
  transform: translateX(79px);
}
.side {
  position: relative;
  height: 26px;
  padding: 0;
  border: 0;
  border-radius: 13px;
  background: none;
  color: var(--muted);
  font-size: 12px;
  line-height: 26px;
  font-weight: 500;
  cursor: pointer;
  transition: color 0.2s ease;
}
.side[aria-checked='true'] {
  color: var(--ink);
}
.side:hover {
  color: var(--ink);
}

.send {
  background: var(--send-on);
  color: var(--send-on-ink);
}
.send[aria-disabled='true'] {
  background: var(--send-off);
  color: var(--send-off-ink);
  cursor: default;
}
.arrow {
  grid-area: 1 / 1;
  width: 13px;
  height: 13px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
  transition:
    opacity 0.2s ease,
    transform 0.2s ease;
}
.spinner {
  grid-area: 1 / 1;
  width: 14px;
  height: 14px;
  border: 1.6px solid currentColor;
  border-right-color: transparent;
  border-radius: 50%;
  opacity: 0;
  transform: scale(0.6);
  transition:
    opacity 0.2s ease,
    transform 0.2s ease;
}
.send[data-busy] {
  background: var(--send-on);
  color: var(--send-on-ink);
}
.send[data-busy] .arrow {
  opacity: 0;
  transform: translateY(-4px);
}
.send[data-busy] .spinner {
  opacity: 1;
  transform: none;
  animation: spin 0.7s linear infinite;
}
@keyframes spin {
  to {
    transform: rotate(1turn);
  }
}

/* ——— The sources ——— */
.sources {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 8px;
  margin-top: 16px;
}
.source {
  display: inline-flex;
  color: var(--ink);
  align-items: center;
  gap: 7px;
  height: 32px;
  padding: 0 14px 0 9.5px;
  border: 1px solid var(--chip-edge);
  border-radius: 16px;
  background: var(--chip);
  font-size: 14px;
  line-height: 20px;
  font-weight: 500;
  cursor: pointer;
  transition:
    transform 0.15s ease,
    background-color 0.2s ease;
}
.sourceIcon {
  flex: none;
  width: 12px;
  height: 12px;
  fill: none;
  stroke: var(--muted);
  stroke-width: 1.45;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.source:hover:not(:disabled) {
  background: color-mix(in srgb, var(--chip) 88%, var(--ink));
}
.source:disabled {
  cursor: default;
  opacity: 0.55;
}

/* Presses: the reader's, and the demo's (data-pressed). */
.plus:active:not(:disabled),
.send:active:not([aria-disabled='true']),
.send[data-pressed],
.source:active:not(:disabled) {
  transform: scale(0.93);
}
.source:active:not(:disabled) {
  transform: scale(0.96);
}
.side:active,
.side[data-pressed] {
  transform: scale(0.95);
}

.root :focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.root .field:focus-visible {
  outline: 0;
}
.composerInner:has(.field:focus-visible),
.composerInner:has(.field[data-pressed]) {
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--focus) 60%, transparent);
}

/* ——— The demo: the file it drags, and its pointer ——— */
.ghost {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 2;
  display: flex;
  align-items: center;
  gap: 6px;
  height: 30px;
  padding: 0 10px 0 8px;
  border: 1px solid var(--chip-edge);
  border-radius: 9px;
  background: var(--card);
  box-shadow: 0 8px 20px rgb(0 0 0 / 0.22);
  font-size: 12px;
  font-weight: 500;
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.25s ease;
  will-change: transform;
}
.ghost[data-shown='true'] {
  opacity: 0.96;
}
.ghostIcon {
  width: 14px;
  height: 14px;
  fill: none;
  stroke: var(--mint-edge);
  stroke-width: 1.3;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.pointerLayer {
  position: absolute;
  inset: 0;
  z-index: 3;
  pointer-events: none;
}
.pointer {
  width: 22px;
  height: 22px;
}
.hand {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .arrowGlyph {
  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 stages: the sources may wrap, the composer's buttons stay on one row. */
@media (max-width: 380px) {
  .root {
    padding: 24px 10px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .scan,
  .strip,
  .guide,
  .attachment,
  .spinner,
  .check {
    animation: none !important;
  }
  .scan {
    opacity: 0;
  }
  .lift,
  .guideLift,
  .thumb,
  .inset,
  .inset::after,
  .arrow,
  .spinner {
    transition: none;
  }
}
```

### contract-dropzone/demo-pointer.tsx

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

'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 file the demo
 * drags, which plays its own animation on the same clock). 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 (contract-dropzone.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 control. 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>
  );
}
```

### contract-dropzone/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.
 */
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, playing: visible && !reduced && !taken };
}
```