# Clause positions

> A negotiation card that sets their draft against your redlines, clause by clause, on tracks you can drag.

Each clause of a contract is a track in three zones: what you accept, what you would discuss, and a hatched walk-away. Their draft is an open ring; your position is a dot joined to it by a dashed arrow. Drag a dot, or use the arrow keys, and the terms change as it moves; bring it back to the ring and the clause is agreed; push it into the walk-away and it turns red and the card says so.

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

Category: AI.

## Anatomy

1. **Card**: A white card on a grey stage with a heading, a line under it and a slider icon; dark by night through light-dark() tokens.
2. **Summary**: How many clauses, how many redlined and, in red, how many past the walk-away; the playbook on the right.
3. **Clause and terms**: The clause’s name, and “their term → yours” on the right, in monospace when the terms carry a unit. Agreed, it just says so.
4. **Track**: Three zones in one bar: acceptable, negotiable, and a hatched walk-away. Pressing anywhere on it jumps the dot there.
5. **Their draft**: An open ring at their term. It fades once your position comes back to it.
6. **Arrow**: A dashed line clipped to the stretch between the ring and the dot, with a chevron that flips with the side.
7. **Redline**: Your position, a slider: drag it, press the track or use the arrow keys, and it settles on the nearest term in 260 ms.
8. **Legend**: What the ring, the dot and the hatching mean, in one line at the foot of the panel.

## When to use

- Reviewing a contract against a playbook, where each clause has a known range of terms you accept, would discuss or walk away from.
- Showing at a glance how far your redlines sit from their draft, clause by clause, and which ones cross a line.
- Terms that run in an order from best to worst for you: months, days, caps, or named options like “mutual, capped”.
- Letting a reviewer move a position term by term and see the summary and the warning follow, before anything is sent.
- Five to eight clauses on one card, beside a document or in an assistant’s panel.

## When not to use

- Editing the clause’s wording itself. Use a redline view of the text, with insertions and deletions in place.
- Free numbers with no fixed set of terms (any amount, any date). Use a number field or a plain slider.
- Terms with no order between them, where “further right” means nothing. Use a select or a set of options.
- A long contract with dozens of clauses. Use a table of clauses with a status column, and open one card per clause.
- Showing a single risk score. Use a meter or a badge: the tracks need a draft and a position to compare.

## Installation

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

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

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 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 { ClausePositions, type Clause } from './clause-positions';

const clauses: Clause[] = [
  {
    name: 'Notice period',
    unit: 'd',
    zones: [0.4, 0.75],
    terms: [
      { at: 0.1, label: '15' },
      { at: 0.3, label: '30' },
      { at: 0.55, label: '60' },
      { at: 0.85, label: '90' },
    ],
    draft: 2,
    redline: 1,
  },
];

export function Review() {
  return <ClausePositions clauses={clauses} onChange={(clause, term) => save(clause, term)} />;
}
```

## States

- **Redlines and agreed**: Three clauses redlined, three on their draft: the summary counts the redlines.
- **Past the walk-away**: A position in the hatched zone turns the dot, the terms and the hatching red, is counted in the summary and announced.
- **All agreed**: Every dot back on its ring: no arrows, plain dots, “agreed”. Their 3 mo liability cap is past your walk-away, so agreeing to it still shows red.
- **All redlined**: An opening position on every clause, some to the left of their draft, some to the right.
- **Light scheme**: `colorScheme="light"`: a white card on a pale grey stage, a dark plain dot.
- **Dark scheme**: `colorScheme="dark"`: a near-black card on a charcoal stage.

## How it works

- Every clause has a list of terms, each at a place on its track from 0 to 1, and two zone bounds. Your position is one of those terms; their draft is another. The label reads “their term → yours”, set in monospace when the terms are numbers with a unit.
- Each track is a size container. The ring and the dot sit on zero-width layers moved by translateX(var(--at) * 100cqw), so a move is a transform and never a layout, and keys or a release glide there in 260 ms.
- The dashed arrow is a single line the width of the track, clipped with clip-path: inset() to the stretch between the ring and the dot, minus the ring on one side and the dot and its chevron on the other. It transitions with the dot, so the two stay together; the chevron flips when the dot passes to the other side of the ring.
- A drag captures the pointer on the track: the dot follows it at once, the label shows the nearest term as it goes, and on release the dot settles on that term. Pressing anywhere on the track jumps there first. Back on their draft the clause is agreed: the ring and the arrow fade, the dot turns plain.
- A position in the walk-away zone turns the dot, the label and the hatching red, adds the count to the summary line and is announced.
- On first view each redline appears on its ring and travels along its arrow to where it stands, in 900 ms, 140 ms after the row above.
- While on screen and untouched, a demo pointer drags a redline term by term into the walk-away and back, then redlines an agreed clause and agrees it again; the dot glides with the pointer on the same 260 ms curve. Any key or press hands it to the reader where it is.
- 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 on a charcoal stage at night, a white one on a pale grey stage by day, where the plain dot turns dark.

## API reference

- **clauses** (`Clause[]`, default `DEFAULT_CLAUSES`): One row each: a name, its terms ({ at, label }, from the most favourable to you to the least, at from 0 to 1), the zones ([end of acceptable, start of walk-away]), the draft’s and the starting redline’s term indexes, and an optional unit that sets the pair in monospace.
- **title** (`string`, default `'Positions'`): The card’s heading.
- **subtitle** (`string`, default `'Their draft vs your redlines'`): The line under the heading.
- **playbook** (`string`, default `'Playbook v3'`): The note on the right of the summary line.
- **onChange** (`(clause: number, term: number) => void`): Called when a position settles on a term, by a drag, a press or a key.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer drags redlines around. Any key or press hands it to the reader.
- **reveal** (`boolean`, default `true`): On first view, each redline travels from its ring to where it stands. false starts them in place and keeps the demo off, for a static instance.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root.

## Accessibility

- The card is a section named by its heading; the clauses are a list. Each dot is a slider named by its clause, with its term index as the value and “24 mo, their draft 12 mo, past your walk-away” or “30 d, agreed” as the value text.
- Arrow keys move one term, Page Up and Page Down two, Home and End to the ends, and Escape back to their draft. Focus shows a ring round the dot and lights the row.
- Entering the walk-away zone is announced in a polite live region; the visible pair of terms is hidden from screen readers, which hear the slider’s value text instead.
- The rings, arrows, zones and the demo pointer are decorative. Under reduced motion the redlines are simply in place, moves are instant and the demo never plays.

## Source

### clause-positions/clause-positions.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import { DemoPointer, runDemo, type Point } from './demo-pointer';
import { useAutoplay } from './use-autoplay';
import styles from './clause-positions.module.css';

/*
 * Clause positions: a negotiation card that sets their draft of a contract against your redlines,
 * one clause a row. Each row is a track in three zones (what you accept, what you'd discuss, and
 * the hatched walk-away), with their draft as an open ring and your position as a dot.
 *
 * - Your position is a slider over the clause's terms. Drag the dot (or press anywhere on the
 *   track, or use the arrow keys on it) and it moves term by term; the label above says
 *   "their term → yours". Off their draft the dot is your redline (violet), joined to the ring by
 *   a dashed arrow; back on it, the clause is agreed and the dot turns plain.
 * - A dot in the walk-away zone turns red, the zone's hatching with it, and the card says so.
 * - When the card first comes into view, each redline leaves its ring and travels along its arrow
 *   to where it stands, one row after the other.
 * - Every dot is a layer of its own, moved by a transform whose length is a share of the track
 *   (container query units), so a move never lays the card out. The arrow is one dashed line the
 *   width of the track, clipped to the stretch between the ring and the dot.
 * - While on screen and untouched, a demo pointer drags a redline into the walk-away and back,
 *   then redlines an agreed clause and agrees it again. Any real input hands it 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.
 */

/** One term a clause can take, and where it sits on the track, from 0 (left) to 1. */
export type ClauseTerm = { at: number; label: string };

export type Clause = {
  name: string;
  /** Its terms, from the most favourable to you (left) to the least. */
  terms: ClauseTerm[];
  /** Where the acceptable zone ends and where the walk-away zone starts, from 0 to 1. */
  zones: [number, number];
  /** Their draft's term, and your redline's (the same when the clause is agreed): indexes. */
  draft: number;
  redline: number;
  /** A unit written after a numeric pair ("3 → 12 mo"), which is then set in monospace. */
  unit?: string;
};

export const DEFAULT_CLAUSES: Clause[] = [
  {
    name: 'Liability cap',
    unit: 'mo',
    zones: [0.34, 0.6],
    terms: [
      { at: 0.04, label: '24' },
      { at: 0.1, label: '18' },
      { at: 0.16, label: '12' },
      { at: 0.26, label: '9' },
      { at: 0.38, label: '6' },
      { at: 0.5, label: '5' },
      { at: 0.68, label: '4' },
      { at: 0.86, label: '3' },
      { at: 0.96, label: '1' },
    ],
    draft: 7,
    redline: 2,
  },
  {
    name: 'Indemnity',
    zones: [0.3, 0.68],
    terms: [
      { at: 0.08, label: 'theirs only' },
      { at: 0.2, label: 'mutual' },
      { at: 0.4, label: 'mutual, capped' },
      { at: 0.6, label: '1-way' },
      { at: 0.86, label: '1-way, uncapped' },
    ],
    draft: 3,
    redline: 1,
  },
  {
    name: 'Auto-renewal',
    unit: 'mo',
    zones: [0.4, 0.72],
    terms: [
      { at: 0.16, label: '0' },
      { at: 0.3, label: '1' },
      { at: 0.42, label: '3' },
      { at: 0.52, label: '6' },
      { at: 0.62, label: '12' },
      { at: 0.8, label: '24' },
      { at: 0.92, label: '36' },
    ],
    draft: 4,
    redline: 1,
  },
  {
    name: 'Payment terms',
    unit: 'd',
    zones: [0.45, 0.75],
    terms: [
      { at: 0.1, label: '15' },
      { at: 0.24, label: '30' },
      { at: 0.38, label: '45' },
      { at: 0.52, label: '60' },
      { at: 0.66, label: '90' },
      { at: 0.86, label: '120' },
    ],
    draft: 1,
    redline: 1,
  },
  {
    name: 'Termination',
    unit: 'd',
    zones: [0.38, 0.7],
    terms: [
      { at: 0.14, label: '15' },
      { at: 0.3, label: '30' },
      { at: 0.46, label: '60' },
      { at: 0.6, label: '90' },
      { at: 0.8, label: '180' },
      { at: 0.94, label: '365' },
    ],
    draft: 1,
    redline: 1,
  },
  {
    name: 'Governing law',
    zones: [0.5, 0.8],
    terms: [
      { at: 0.12, label: 'ours' },
      { at: 0.34, label: 'neutral' },
      { at: 0.58, label: 'their state' },
      { at: 0.88, label: 'their country' },
    ],
    draft: 2,
    redline: 2,
  },
];

/** How long a redline takes to travel from its ring on first view, and the stagger per row. */
const REVEAL = 900;
const STAGGER = 140;
const REVEAL_DELAY = 250;
/** The demo's drag: one term every STEP ms, the dot gliding with the pointer. */
const STEP = 260;
const PAUSE = 120;

const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));

/** The term nearest a place on the track. */
function nearest(terms: ClauseTerm[], at: number) {
  let best = 0;
  terms.forEach((term, index) => {
    if (Math.abs(term.at - at) < Math.abs(terms[best].at - at)) best = index;
  });
  return best;
}

/** A term with its unit, for screen readers and announcements. */
const spoken = (clause: Clause, index: number) =>
  clause.unit ? `${clause.terms[index].label} ${clause.unit}` : clause.terms[index].label;

export function ClausePositions({
  clauses = DEFAULT_CLAUSES,
  title = 'Positions',
  subtitle = 'Their draft vs your redlines',
  playbook = 'Playbook v3',
  onChange,
  autoplay = true,
  reveal = true,
  colorScheme,
  className = '',
}: {
  /** The clauses, one row each, with their terms, zones, draft and starting redline. */
  clauses?: Clause[];
  /** The card's heading and the line under it. */
  title?: string;
  subtitle?: string;
  /** The note on the right of the summary line (the playbook the zones come from). */
  playbook?: string;
  /** Called when a position settles on a term: the clause's index and the term's. */
  onChange?: (clause: number, term: number) => void;
  /** While on screen and untouched, a demo pointer drags redlines around. */
  autoplay?: boolean;
  /**
   * On first view, each redline travels from its ring to where it stands. false: they start in
   * place, and the demo stays off (for a static or documented instance).
   */
  reveal?: 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>(5000);
  const uid = useId();
  /** Your position on each clause: a term's index. */
  const [positions, setPositions] = useState(() => clauses.map((clause) => clause.redline));
  /** The reader's drag: the clause, and the place on its track (0 to 1) it is at. */
  const [drag, setDrag] = useState<{ row: number; at: number } | null>(null);
  /** The redlines have left their rings (on first view), and are still on their way. */
  const [revealed, setRevealed] = useState(!reveal);
  const [revealing, setRevealing] = useState(false);
  /** The row the demo's pointer is over, and whether it is dragging. */
  const [demoHover, setDemoHover] = useState<number | null>(null);
  const [demoDrag, setDemoDrag] = useState(false);
  const [message, setMessage] = useState('');
  const tracks = useRef<(HTMLDivElement | null)[]>([]);
  const dots = useRef<(HTMLSpanElement | null)[]>([]);
  const pointer = useRef<HTMLSpanElement>(null);
  const warned = useRef<boolean[]>([]);

  // New clauses: start again from their redlines.
  const [shown, setShown] = useState(clauses);
  if (shown !== clauses) {
    setShown(clauses);
    setPositions(clauses.map((clause) => clause.redline));
    setDrag(null);
  }

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

  // The first view: each redline travels from its ring to where it stands. Under reduced motion
  // they are simply there.
  useEffect(() => {
    const element = root.current;
    if (!element || !reveal) return;
    let timer = 0;
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (!entry.isIntersecting) return;
        observer.disconnect();
        if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
          setRevealed(true);
          return;
        }
        setRevealing(true);
        // One frame on the rings, so the move is a transition.
        requestAnimationFrame(() => requestAnimationFrame(() => setRevealed(true)));
        timer = window.setTimeout(
          () => setRevealing(false),
          REVEAL_DELAY + REVEAL + STAGGER * clauses.length + 100,
        );
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    return () => {
      observer.disconnect();
      window.clearTimeout(timer);
    };
  }, [root, clauses.length, reveal]);

  const rows = clauses.map((clause, index) => {
    const term = positions[index] ?? clause.redline;
    const dragging = drag?.row === index;
    const at = dragging ? drag.at : clause.terms[term].at;
    const draftAt = clause.terms[clause.draft].at;
    const agreed = term === clause.draft;
    return {
      clause,
      term,
      dragging,
      agreed,
      // Before the first view, a redline waits on its ring.
      at: revealed ? at : draftAt,
      draftAt,
      warn: clause.terms[term].at >= clause.zones[1],
    };
  });
  const redlined = rows.filter((row) => !row.agreed).length;
  const warnings = rows.filter((row) => row.warn).length;

  // Say so when a position enters the walk-away zone.
  const warnKey = rows.map((row) => (row.warn ? 1 : 0)).join('');
  const onWarn = useEffectEvent(() => {
    rows.forEach((row, index) => {
      if (row.warn && !warned.current[index] && revealed)
        announce(`${row.clause.name}: ${spoken(row.clause, row.term)} is past your walk-away.`);
      warned.current[index] = row.warn;
    });
  });
  useEffect(() => onWarn(), [warnKey]);

  const settle = (row: number, term: number) => {
    setPositions((now) => now.map((value, index) => (index === row ? term : value)));
    onChange?.(row, term);
  };

  /** The place on a track under the pointer, kept within the clause's terms. */
  const placeOf = (row: number, clientX: number) => {
    const track = tracks.current[row];
    const { terms } = clauses[row];
    if (!track) return terms[0].at;
    const rect = track.getBoundingClientRect();
    const at = (clientX - rect.left) / (rect.width || 1);
    return clamp(at, terms[0].at, terms[terms.length - 1].at);
  };

  const onPointerDown = (row: number) => (event: ReactPointerEvent<HTMLDivElement>) => {
    if (event.button !== 0) return;
    event.preventDefault();
    event.currentTarget.setPointerCapture(event.pointerId);
    dots.current[row]?.focus({ preventScroll: true });
    const at = placeOf(row, event.clientX);
    setDrag({ row, at });
    setPositions((now) =>
      now.map((value, index) => (index === row ? nearest(clauses[row].terms, at) : value)),
    );
  };
  const onPointerMove = (row: number) => (event: ReactPointerEvent<HTMLDivElement>) => {
    if (drag?.row !== row) return;
    const at = placeOf(row, event.clientX);
    setDrag({ row, at });
    setPositions((now) =>
      now.map((value, index) => (index === row ? nearest(clauses[row].terms, at) : value)),
    );
  };
  const onPointerUp = (row: number) => () => {
    if (drag?.row !== row) return;
    setDrag(null);
    settle(row, nearest(clauses[row].terms, drag.at));
  };

  const onKeyDown = (row: number) => (event: ReactKeyboardEvent) => {
    const last = clauses[row].terms.length - 1;
    const now = positions[row];
    const next =
      {
        ArrowLeft: now - 1,
        ArrowDown: now - 1,
        ArrowRight: now + 1,
        ArrowUp: now + 1,
        PageDown: now - 2,
        PageUp: now + 2,
        Home: 0,
        End: last,
        // Back to their draft: the clause is agreed.
        Escape: clauses[row].draft,
      }[event.key] ?? null;
    if (next === null) return;
    event.preventDefault();
    const term = clamp(next, 0, last);
    if (term !== now) settle(row, term);
  };

  // The demo: drag the auto-renewal redline into the walk-away and back, then redline an agreed
  // clause and agree it again.
  const demo = autoplay && reveal && playing && revealed && !revealing;
  const demoMove = useEffectEvent((row: number, term: number) =>
    setPositions((now) => now.map((value, index) => (index === row ? term : value))),
  );
  const demoState = useEffectEvent((hover: number | null, dragging: boolean) => {
    setDemoHover(hover);
    setDemoDrag(dragging);
  });
  const demoStart = useEffectEvent(() => positions.slice());
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!demo || !stage || !arrow) return;
    const saved = demoStart();
    // The script's rows: the first clause with a redline that can reach the walk-away, and the
    // first agreed one.
    const redlineRow = clauses.findIndex(
      (clause, index) => saved[index] !== clause.draft && clause.terms.length > 2,
    );
    const agreedRow = clauses.findIndex(
      (clause, index) => saved[index] === clause.draft && clause.terms.length > 1,
    );

    /** A place on a row's track, in the stage's own px. */
    const spot = (row: number, at: number, dy = 0): Point => {
      const frame = stage.getBoundingClientRect();
      const scale = frame.width / stage.offsetWidth || 1;
      const rect = tracks.current[row]?.getBoundingClientRect();
      if (!rect) return { x: stage.offsetWidth / 2, y: stage.offsetHeight / 2 };
      return {
        x: (rect.left - frame.left + rect.width * at) / scale,
        y: (rect.top - frame.top + rect.height / 2) / scale + dy,
      };
    };

    const stop = runDemo(stage, arrow, async ({ wait, glide, glyph }) => {
      for (;;) {
        // Drags a row's dot term by term, from `from` to `to`.
        const dragAlong = async (row: number, from: number, to: number) => {
          const dir = Math.sign(to - from);
          for (let term = from + dir; dir && term !== to + dir; term += dir) {
            demoMove(row, term);
            await glide(spot(row, clauses[row].terms[term].at), STEP, true);
            await wait(PAUSE);
          }
        };
        await wait(600);
        if (redlineRow >= 0) {
          const clause = clauses[redlineRow];
          const start = saved[redlineRow];
          // The first term past the walk-away line, or the last one.
          const far = clause.terms.findIndex((term) => term.at >= clause.zones[1]);
          const target = far >= 0 ? far : clause.terms.length - 1;
          demoState(redlineRow > 0 ? redlineRow - 1 : redlineRow, false);
          await glide(spot(Math.max(0, redlineRow - 1), 0.5, -18), 700);
          await wait(500);
          demoState(redlineRow, false);
          await glide(spot(redlineRow, clause.terms[start].at), 640);
          glyph('hand');
          await wait(260);
          demoState(redlineRow, true);
          await dragAlong(redlineRow, start, target);
          await wait(1400);
          await dragAlong(redlineRow, target, start);
          demoState(redlineRow, false);
          await wait(380);
          glyph('arrow');
        }
        if (agreedRow >= 0) {
          const clause = clauses[agreedRow];
          const start = saved[agreedRow];
          const to = start > 0 ? start - 1 : start + 1;
          demoState(agreedRow, false);
          await glide(spot(agreedRow, clause.terms[start].at), 700);
          glyph('hand');
          await wait(260);
          demoState(agreedRow, true);
          await dragAlong(agreedRow, start, to);
          await wait(1200);
          await dragAlong(agreedRow, to, start);
          demoState(agreedRow, false);
          await wait(380);
          glyph('arrow');
        }
        demoState(null, false);
        await glide({ x: stage.offsetWidth * 0.82, y: stage.offsetHeight * 0.94 }, 800);
        await wait(2600);
      }
    });
    return () => {
      stop();
      // Cut short, the demo leaves each clause where it was: the reader carries on from there.
      demoState(null, false);
    };
  }, [demo, root, clauses]);

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="stage"
      data-color-scheme={colorScheme}
      data-revealed={revealed || undefined}
      data-revealing={revealing || undefined}
      data-demo={demoDrag || undefined}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onPointerMove={(event) => {
        // The reader's own pointer in the stage takes over from the demo's.
        if (event.pointerType !== 'touch') take();
      }}
    >
      <section className={styles.card} data-part="card" aria-labelledby={`${uid}-title`}>
        <header className={styles.header} data-part="header">
          <span className={styles.icon} data-part="icon" aria-hidden="true">
            <svg viewBox="0 0 14 10" focusable="false">
              <path className={styles.iconLine} d="M1 2.5h12M1 8.5h12" />
              <circle className={styles.iconKnob} cx="5" cy="2.5" r="2" />
              <circle className={styles.iconKnob} cx="9.5" cy="8.5" r="2" />
            </svg>
          </span>
          <div className={styles.heading}>
            <h3 id={`${uid}-title`} className={styles.title} data-part="title">
              {title}
            </h3>
            <p className={styles.subtitle} data-part="subtitle">
              {subtitle}
            </p>
          </div>
        </header>

        <div className={styles.panel} data-part="panel">
          <p className={styles.summary} data-part="summary">
            <span>
              {clauses.length} clauses · {redlined} redlined
              {warnings > 0 && <span className={styles.over}> · {warnings} walk-away</span>}
            </span>
            <span className={styles.playbook} data-part="playbook">
              {playbook}
            </span>
          </p>

          <ul className={styles.rows} data-part="rows">
            {rows.map((row, index) => {
              const { clause, term, agreed, warn, at, draftAt } = row;
              const left = at <= draftAt;
              const lo = Math.min(at, draftAt);
              const hi = Math.max(at, draftAt);
              const labelId = `${uid}-clause-${index}`;
              const value = agreed ? (
                'agreed'
              ) : (
                <>
                  {clause.terms[clause.draft].label} <span className={styles.to}>→</span>{' '}
                  {clause.terms[term].label}
                  {clause.unit && ` ${clause.unit}`}
                </>
              );
              return (
                <li
                  key={`${clause.name}-${index}`}
                  className={styles.row}
                  data-part="row"
                  data-state={agreed ? 'agreed' : 'redlined'}
                  data-warn={warn || undefined}
                  data-hover={demoHover === index || undefined}
                  data-dragging={row.dragging || undefined}
                  style={
                    {
                      '--i': index,
                      '--at': at,
                      '--from': draftAt,
                      '--lo': lo,
                      '--hi': hi,
                      '--ok': clause.zones[0],
                      '--walk': clause.zones[1],
                    } as CSSProperties
                  }
                >
                  <div className={styles.rowHead}>
                    <span id={labelId} className={styles.name} data-part="clause">
                      {clause.name}
                    </span>
                    <span
                      className={styles.value}
                      data-part="value"
                      data-mono={(clause.unit && !agreed) || undefined}
                      aria-hidden="true"
                    >
                      {value}
                    </span>
                  </div>
                  <div
                    ref={(element) => {
                      tracks.current[index] = element;
                    }}
                    className={styles.track}
                    data-part="track"
                    onPointerDown={onPointerDown(index)}
                    onPointerMove={onPointerMove(index)}
                    onPointerUp={onPointerUp(index)}
                    onPointerCancel={onPointerUp(index)}
                  >
                    <span className={styles.zoneOk} data-part="zone-acceptable" />
                    <span className={styles.zoneMid} data-part="zone-negotiable" />
                    <span className={styles.zoneWalk} data-part="zone-walk-away" />
                    <span
                      className={styles.arrow}
                      data-part="arrow"
                      data-side={left ? 'left' : 'right'}
                      aria-hidden="true"
                    />
                    <span className={styles.draftLayer} aria-hidden="true">
                      <span className={styles.draft} data-part="draft" />
                    </span>
                    <span className={styles.dotLayer} data-side={left ? 'left' : 'right'}>
                      <svg
                        className={styles.chevron}
                        viewBox="0 0 8 9"
                        aria-hidden="true"
                        focusable="false"
                      >
                        <path d="M7.2.8 1.2 4.5l6 3.7" />
                      </svg>
                      <span
                        ref={(element) => {
                          dots.current[index] = element;
                        }}
                        className={styles.dot}
                        data-part="redline"
                        role="slider"
                        tabIndex={0}
                        aria-labelledby={labelId}
                        aria-valuemin={0}
                        aria-valuemax={clause.terms.length - 1}
                        aria-valuenow={term}
                        aria-valuetext={
                          agreed
                            ? `${spoken(clause, term)}, agreed`
                            : `${spoken(clause, term)}, their draft ${spoken(clause, clause.draft)}${warn ? ', past your walk-away' : ''}`
                        }
                        onKeyDown={onKeyDown(index)}
                      />
                    </span>
                  </div>
                </li>
              );
            })}
          </ul>

          <ul className={styles.legend} data-part="legend">
            <li>
              <span className={styles.legendDraft} aria-hidden="true" />
              Their draft
            </li>
            <li>
              <span className={styles.legendRedline} aria-hidden="true" />
              Redline
            </li>
            <li>
              <span className={styles.legendWalk} aria-hidden="true" />
              Walk-away
            </li>
          </ul>
        </div>
      </section>

      <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>
  );
}
```

### clause-positions/clause-positions.module.css

```css
/* Clause positions (clause-positions.tsx): a dark card on a dark stage by night, a white one on a
   pale stage by day. Inside it, a sunken panel with one row per clause: a name, a value, and a
   track in three zones with their draft (a ring) and your position (a dot) on it.

   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).

   Every track is a size container: the ring and the dot sit on zero-width layers moved by
   translateX(var(--at) * 100cqw), so a move is a transform and never a layout. The dashed arrow is
   one line the width of the track, clipped (inset) to the stretch between the ring and the dot. */

.root {
  /* The stage, the card, and the sunken panel inside it. */
  --stage: #f1f1f2;
  --card: #fff;
  --card-edge: rgb(0 0 0 / 0.07);
  --card-shadow: rgb(0 0 0 / 0.06);
  --panel: #fafafa;
  --panel-edge: #ebebed;
  /* The header's round icon. */
  --icon: #efeff1;
  --icon-line: #b4b4b9;
  --icon-knob: #47474c;
  /* Words: names, the quieter values, and the quietest notes. */
  --ink: #111113;
  --muted: #6c6c72;
  --faint: #9f9fa5;
  /* The track's zones: acceptable (the strongest), negotiable, and the hatched walk-away. */
  --zone-ok: #e3e3e6;
  --zone-mid: #ededef;
  --zone-walk: #f5f5f6;
  --walk-edge: #e4e4e7;
  --hatch: #d6d6da;
  /* Their draft (the ring, and an agreed dot), your redline, and the dashed arrow between. */
  --draft: #141416;
  --redline: #5d5ff5;
  --chevron: #8c8c92;
  --dash: #a2a2a8;
  /* Past the walk-away. */
  --warn: #e0343c;
  --warn-hatch: rgb(224 52 60 / 0.32);
  /* A row under the pointer. */
  --row-hover: #f2f2f4;
  /* 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);
  --ease: cubic-bezier(0.22, 1, 0.36, 1);
  position: relative;
  display: grid;
  place-items: center;
  box-sizing: border-box;
  width: 100%;
  min-height: 560px;
  padding: 40px 16px;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--stage);
  color: var(--ink);
  font:
    500 12.5px/16px Satoshi,
    Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    Arial,
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --stage: light-dark(#f1f1f2, #1e1e1e);
    --card: light-dark(#fff, #111);
    --card-edge: light-dark(rgb(0 0 0 / 0.07), transparent);
    --card-shadow: light-dark(rgb(0 0 0 / 0.06), transparent);
    --panel: light-dark(#fafafa, #0d0d0d);
    --panel-edge: light-dark(#ebebed, #1c1c1c);
    --icon: light-dark(#efeff1, #2d2d2d);
    --icon-line: light-dark(#b4b4b9, #6b6b6b);
    --icon-knob: light-dark(#47474c, #d2d2d2);
    --ink: light-dark(#111113, #fafafa);
    --muted: light-dark(#6c6c72, #969696);
    --faint: light-dark(#9f9fa5, #636363);
    --zone-ok: light-dark(#e3e3e6, #262626);
    --zone-mid: light-dark(#ededef, #1c1c1c);
    --zone-walk: light-dark(#f5f5f6, #141414);
    --walk-edge: light-dark(#e4e4e7, #202020);
    --hatch: light-dark(#d6d6da, #2a2a2a);
    --draft: light-dark(#141416, #f1f1f1);
    --redline: light-dark(#5d5ff5, #7471ff);
    --chevron: light-dark(#8c8c92, #959595);
    --dash: light-dark(#a2a2a8, #777);
    --warn: light-dark(#e0343c, #ff5d63);
    --warn-hatch: light-dark(rgb(224 52 60 / 0.32), rgb(255 93 99 / 0.38));
    --row-hover: light-dark(#f2f2f4, #151515);
  }
}
/* 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;
}

/* The card. */
.card {
  box-sizing: border-box;
  width: min(100%, 326px);
  padding: 11px;
  border-radius: 16px;
  background: var(--card);
  box-shadow:
    0 0 0 1px var(--card-edge),
    0 6px 24px var(--card-shadow);
}

.header {
  display: flex;
  align-items: flex-start;
  gap: 12px;
}
.icon {
  display: grid;
  flex-shrink: 0;
  place-items: center;
  width: 28px;
  height: 28px;
  margin-top: 4px;
  border-radius: 50%;
  background: var(--icon);
}
.icon svg {
  display: block;
  width: 13px;
  height: 9.3px;
  overflow: visible;
}
.iconLine {
  fill: none;
  stroke: var(--icon-line);
  stroke-width: 1.3;
  stroke-linecap: round;
}
.iconKnob {
  fill: var(--icon-knob);
}
.heading {
  min-width: 0;
}
.title {
  margin: 0;
  font-size: 14.5px;
  font-weight: 500;
  line-height: 18px;
  letter-spacing: -0.005em;
}
.subtitle {
  margin: 0;
  color: var(--muted);
  margin-top: 2px;
  font-size: 12.5px;
  line-height: 17px;
}

/* The sunken panel. */
.panel {
  margin-top: 11px;
  padding: 15.5px 15px 14px;
  border: 1px solid var(--panel-edge);
  border-radius: 12px;
  background: var(--panel);
}
.summary {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  margin: 0;
  color: var(--muted);
}
.playbook {
  flex-shrink: 0;
  color: var(--faint);
}
.over {
  color: var(--warn);
}

.rows {
  display: grid;
  gap: 20px;
  margin: 12px 0 0;
  padding: 0;
  list-style: none;
}
.row {
  position: relative;
  isolation: isolate;
}
/* A row under the pointer: a soft plate behind it. */
.row::before {
  position: absolute;
  z-index: -1;
  inset: -10px -9px;
  border-radius: 9px;
  background: var(--row-hover);
  opacity: 0;
  content: '';
  transition: opacity 160ms ease;
}
.row[data-hover]::before,
.row[data-dragging]::before,
.row:focus-within::before {
  opacity: 1;
}
@media (hover: hover) {
  .row:hover::before {
    opacity: 1;
  }
}
.rowHead {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  height: 16px;
}
.name {
  flex-shrink: 0;
  font-weight: 500;
}
.value {
  overflow: hidden;
  color: var(--muted);
  text-overflow: ellipsis;
  white-space: nowrap;
  transition: color 160ms ease;
}
.row[data-state='agreed'] .value {
  color: var(--faint);
}
.value[data-mono] {
  font-family: JetBrains, 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 0.075em;
}
/* The arrow between the two terms: the monospace one, long and thin, in both faces. */
.to {
  font-family: JetBrains, 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
  font-size: 12px;
  font-weight: 400;
}
.value:not([data-mono]) .to {
  display: inline-block;
  margin: 0 2.5px;
  transform: scaleX(1.35);
}
.row[data-warn] .value {
  color: var(--warn);
}

/* The track and its zones. */
.track {
  position: relative;
  height: 12px;
  margin-top: 8px;
  container-type: inline-size;
  touch-action: pan-y;
  cursor: pointer;
}
/* A taller target than the track itself, for fingers. */
.track::before {
  position: absolute;
  inset: -10px -6px;
  content: '';
}
.zoneOk,
.zoneMid,
.zoneWalk {
  position: absolute;
  top: 0;
  bottom: 0;
  border-radius: 3px;
}
.zoneOk {
  left: 0;
  width: calc(var(--ok) * 100% - 1px);
  background: var(--zone-ok);
}
.zoneMid {
  left: calc(var(--ok) * 100% + 1px);
  width: calc((var(--walk) - var(--ok)) * 100% - 2px);
  background: var(--zone-mid);
}
.zoneWalk {
  left: calc(var(--walk) * 100% + 1px);
  right: 0;
  background: repeating-linear-gradient(135deg, var(--hatch) 0 1.1px, transparent 1.1px 3.54px)
    var(--zone-walk);
  box-shadow: inset 0 0 0 1px var(--walk-edge);
}
.row[data-warn] .zoneWalk {
  background: repeating-linear-gradient(135deg, var(--warn-hatch) 0 1.1px, transparent 1.1px 3.54px)
    var(--zone-walk);
}

/* The dashed arrow: the track's width, clipped to between the ring and the dot (minus the ring
   on one side and the dot and its chevron on the other). */
.arrow {
  position: absolute;
  top: 5.5px;
  left: 0;
  right: 0;
  height: 1px;
  background: repeating-linear-gradient(90deg, var(--dash) 0 2px, transparent 2px 5.5px);
  clip-path: inset(0 calc((1 - var(--hi)) * 100% + 11px) 0 calc(var(--lo) * 100% + 14.5px));
  transition:
    clip-path 260ms var(--ease),
    opacity 160ms ease;
}
.arrow[data-side='right'] {
  clip-path: inset(0 calc((1 - var(--hi)) * 100% + 14.5px) 0 calc(var(--lo) * 100% + 11px));
}
.row[data-state='agreed'] .arrow {
  opacity: 0;
}

/* Their draft: an open ring. */
.draftLayer,
.dotLayer {
  position: absolute;
  top: 6px;
  left: 0;
  width: 0;
  height: 0;
  pointer-events: none;
}
.draftLayer {
  transform: translateX(calc(var(--from) * 100cqw));
}
.draft {
  position: absolute;
  top: -5.7px;
  left: -5.7px;
  box-sizing: border-box;
  width: 11.4px;
  height: 11.4px;
  border: 1.6px solid var(--draft);
  border-radius: 50%;
  background: var(--panel);
  transition:
    opacity 180ms ease,
    transform 220ms var(--ease);
}
.row[data-state='agreed'] .draft {
  opacity: 0;
  transform: scale(0.6);
}

/* Your position: the dot, and the chevron that ends the arrow beside it. */
.dotLayer {
  z-index: 1;
  transform: translateX(calc(var(--at) * 100cqw));
  transition: transform 260ms var(--ease);
}
.dot {
  position: absolute;
  top: -4.5px;
  left: -4.5px;
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: var(--redline);
  box-shadow: 0 0 0 1.5px var(--panel);
  outline: none;
  pointer-events: auto;
  cursor: grab;
  touch-action: none;
  transition:
    background-color 180ms ease,
    transform 180ms var(--ease),
    opacity 180ms ease;
}
/* A taller target than the dot itself. */
.dot::before {
  position: absolute;
  inset: -9px;
  border-radius: 50%;
  content: '';
}
.dot:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2.5px;
}
.row[data-dragging] .dot {
  cursor: grabbing;
}
.row[data-state='agreed'] .dot {
  background: var(--draft);
  transform: scale(0.889);
}
.row[data-warn] .dot {
  background: var(--warn);
}
.chevron {
  position: absolute;
  top: -4.5px;
  left: 6.3px;
  width: 8px;
  height: 9px;
  overflow: visible;
  fill: none;
  stroke: var(--chevron);
  stroke-width: 1.1;
  stroke-linecap: round;
  stroke-linejoin: round;
  transition: opacity 160ms ease;
}
.dotLayer[data-side='right'] .chevron {
  left: -14.3px;
  transform: scaleX(-1);
}
.row[data-state='agreed'] .chevron {
  opacity: 0;
}

/* Moves: the reader's drag follows the finger at once; the demo's drag keeps its pointer's pace
   and curve; the first view sends the redlines out one row after the other. */
.row[data-dragging] .dotLayer,
.row[data-dragging] .arrow {
  transition: none;
}
.root[data-demo] .row[data-hover] .dotLayer {
  transition: transform 260ms cubic-bezier(0.45, 0, 0.25, 1);
}
.root[data-demo] .row[data-hover] .arrow {
  transition:
    clip-path 260ms cubic-bezier(0.45, 0, 0.25, 1),
    opacity 160ms ease;
}
.root[data-revealing] .dotLayer {
  transition: transform 900ms var(--ease) calc(250ms + var(--i) * 140ms);
}
.root[data-revealing] .dot,
.root[data-revealing] .chevron {
  transition:
    transform 240ms var(--ease) calc(250ms + var(--i) * 140ms),
    opacity 200ms ease calc(250ms + var(--i) * 140ms);
}
/* Before the first view, a redline waits hidden on its ring. */
.root:not([data-revealed]) .row[data-state='redlined'] .dot {
  opacity: 0;
  transform: scale(0.3);
}
.root:not([data-revealed]) .chevron {
  opacity: 0;
}
.root[data-revealing] .arrow {
  transition: clip-path 900ms var(--ease) calc(250ms + var(--i) * 140ms);
}

/* The legend. */
.legend {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 18px;
  margin: 12px 0 0;
  padding: 0;
  color: var(--muted);
  list-style: none;
}
.legend li {
  display: flex;
  align-items: center;
  gap: 7px;
}
.legendDraft,
.legendRedline,
.legendWalk {
  display: block;
  flex-shrink: 0;
}
.legendDraft {
  box-sizing: border-box;
  width: 10px;
  height: 10px;
  border: 2px solid var(--draft);
  border-radius: 50%;
}
.legendRedline {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: var(--redline);
}
.legendWalk {
  width: 12px;
  height: 8px;
  background: repeating-linear-gradient(135deg, var(--faint) 0 1.2px, transparent 1.2px 3.6px);
  opacity: 0.8;
}

/* The demo pointer, over everything; one of its glyphs shows at a time. */
.pointerLayer {
  position: absolute;
  inset: 0;
  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;
}

@media (prefers-reduced-motion: reduce) {
  .row::before,
  .value,
  .arrow,
  .draft,
  .dotLayer,
  .dot,
  .chevron,
  .root[data-demo] .row[data-hover] .dotLayer,
  .root[data-demo] .row[data-hover] .arrow,
  .root[data-revealing] .dotLayer,
  .root[data-revealing] .dot,
  .root[data-revealing] .chevron,
  .root[data-revealing] .arrow {
    transition: none;
  }
}
```

### clause-positions/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.
 * A drag is a straight flight, so a dot dragged along with the pointer stays under it. The arrow
 * and the hand are HTML boxes that swap by their visibility: a change of style inside an SVG
 * would lay the SVG out again.
 */

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

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer to `to`, in the stage's own px, in `ms`: on a curve, or straight. */
  glide: (to: Point, ms: number, straight?: boolean) => 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, straight) => {
    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. A drag
    // goes straight.
    const off = straight ? 0 : 0.18;
    const bend = { x: (from.x + to.x) / 2 - dy * off, y: (from.y + to.y) / 2 + dx * off };
    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,
    );
    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 (clause-positions.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 hand over a dot it drags. 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>
  );
}
```

### clause-positions/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 };
}
```