# Page risk map

> A contract drawn one tile per page, with each clause the review found marked where it sits: matched, to review or deviating.

A card that maps a reviewed contract: 42 tiny pages, each a few grey lines, with a green tick in the margin where a clause matches the playbook, an amber highlight where one needs a look and a red one where it deviates. Point at a page, or walk the grid with the arrow keys, and a tooltip tied to it by a dotted leader tells the clause and the change it needs. The legend filters the pages by verdict, the pill steps through the ones to fix, and when the card comes into view a scan runs down the grid.

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

Category: AI.

## Anatomy

1. **Header**: A round icon, the title and the document under review, cut with an ellipsis when it is long.
2. **To-fix pill**: Counts the pages that deviate or need a review; pressing it selects the next one after the selection.
3. **Page tile**: A toggle button drawing the page’s seven lines, in a 7-column grid with one tab stop.
4. **Highlight**: A clause to fix: three lines washed red (deviates) or amber (review), behind a bar in the margin.
5. **Tick**: A matched clause: a green tick in the margin over the two lines it covers.
6. **Selection ring**: Drawn round the selected tile, 2 px off it. It glides to the next one by a transform in 260 ms.
7. **Tooltip**: The section and page, then their term → the playbook’s, tied to its tile by a dotted leader. It flips to the left near the right edge.
8. **Legend**: Toggle buttons with a swatch and a count; one on dims every page with another verdict to 28 %.

## When to use

- A reviewed contract or long document where where a finding sits matters as much as what it is.
- A first look before reading: how many pages, how many clauses, and where the ones to fix cluster.
- Jumping to the pages to fix in order, from the “to fix” pill or the arrow keys, with the finding in a tooltip.
- A side panel next to the document viewer, where selecting a page scrolls the viewer to it (`onSelectPage`).

## When not to use

- Reading or editing the clauses themselves. Use a redline review that shows their text against the playbook.
- Comparing the findings of many contracts. Use a table with a column per verdict.
- Short documents of two or three pages: a plain list of the clauses says it with less.
- Findings with no place in the document, such as a missing clause. List them separately; the map only draws what is on a page.
- Scores or trends over time. Use a chart; the tiles carry a verdict, not a quantity.

## Installation

Install it with the shadcn CLI, into components/page-risk-map of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/page-risk-map.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 { PageRiskMap } from './page-risk-map';

export function ContractReview() {
  return (
    <PageRiskMap
      document="Larkspur MSA v4"
      defaultPage={17}
      autoplay={false}
      onSelectPage={(page) => console.log('page', page)}
    />
  );
}
```

## States

- **Deviating page selected**: The default: page 17, the liability cap, ringed, with their 3 mo against the playbook’s 12 mo.
- **Page to review selected**: An amber highlight; the tooltip tells the indemnity, one-way in their draft, mutual in the playbook.
- **Matched page selected**: A tick, and the agreed term in green with a check.
- **Hovering another page**: The tooltip follows the pointer or the focus; the ring stays on the selection.
- **Tooltip on the left**: On the last column there is no room on the right, so the tooltip and its leader go left.
- **Schedule page**: The last three pages are tables, with no clauses; the tooltip names the schedule.
- **Nothing selected**: `defaultPage={null}`: no ring and no tooltip until a page is pointed at or focused.
- **Pointing, nothing selected**: With no selection the tooltip still tells the page under the pointer, with no ring.
- **Filtered: Deviates**: Every page without a deviating clause dims to 28 %; the other legend entries fade.
- **Filtered: Review**: The two pages to review stay bright. Escape in the grid, or the entry again, shows them all.
- **Filtered: Matched**: Fifteen pages with a tick stay bright: what the review found in line with the playbook.
- **Dark**: `colorScheme="dark"`: near-black panel, grey pages, the same verdict colours.

## How it works

- The card is drawn in a unit of its own, --u: 1 px at its 326 px design width, set from the stage’s width by a container query (100cqw / 326, between 0.66 and 1.5 px). Every length is a multiple of it, so the card scales as a whole and keeps its proportions at any size.
- Each page is a button drawing seven 2 px lines, 5 px apart, at lengths taken from the page itself; the schedules at the end are drawn as tables. A matched clause is a 9 px tick in the margin over two lines; a clause to fix washes three lines in its colour behind a 1.75 px bar.
- The selected page wears a ring 2 px off the tile. The ring, the tooltip and the dotted leader between them are placed by transforms read from the tiles’ offsets, so moving from page to page slides them in 200–260 ms without laying anything out. The tooltip sits 21 px to the right of its tile, or to the left when it would leave the panel.
- The tooltip tells the page under the pointer, else the one with the keyboard focus, else the selected one: its section and page in a light mono, then their term and the playbook’s, or the agreed term of a matched clause.
- The legend’s entries are toggle buttons: one dims every page with another verdict to 28 %, pressing it again shows them all. The “to fix” pill selects the next page with a clause to fix.
- When the card first comes into view, a soft line runs down the grid in 760 ms while the tiles rise into place behind it, row by row (95 ms a row, 14 ms a column), then the marks grow from the margin. Until then the tiles wait out of sight; under reduced motion they are simply there.
- While on screen and untouched, a demo pointer hovers the two pages to review, filters by Review and by Deviates, selects a page and comes back to the deviating one, then scans again. Any key, press or pointer move hands the card 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: near-black panels with grey pages at night, white pages on a pale panel by day.

## API reference

- **title** (`string`, default `'Risk by page'`): The card’s title.
- **document** (`string`, default `'Larkspur MSA v4'`): The document under review, under the title; it also names the grid for screen readers.
- **defaultPage** (`number | null`, default `17`): The page selected at first, from 1 to 42, or null for none.
- **onSelectPage** (`(page: number | null) => void`): Called with the page number when a page is selected, or null when the selection is cleared.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer hovers, filters and selects pages. Any real input hands it to the reader.
- **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.
- **defaultState** (`{ filter?: 'deviates' | 'review' | 'matched' | null; hover?: number | null }`): Opens with the legend filtered and a page (1 to 42) under the pointer, with no scan; it also turns the demo off. For docs and tests.
- **className** (`string`): Extra class for the root.

## Accessibility

- The pages are toggle buttons (aria-pressed) in one tab stop: the arrow keys move across the grid, Home and End go to the ends of a row (with Ctrl, of the document), Enter or Space selects. Each is named in full: “Page 17, § 9.2 Liability cap, deviates: 3 mo in their draft, 12 mo in the playbook”.
- The tooltip shows for keyboard focus as it does for hover, and repeats what the focused page’s name already says, so it is hidden from screen readers. Focus rings are visible on the pages, the legend and the pill.
- The legend is a group of toggle buttons; Escape in the grid clears the filter. Selections and filters are announced in a polite live region.
- The page drawings and the icon are decorative (aria-hidden). Under reduced motion there is no scan, the ring and the tooltip move without sliding, and the demo never plays.

## Source

### page-risk-map/page-risk-map.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
  type ReactNode,
} from 'react';
import { DemoPointer, runDemo, type Point } from './demo-pointer';
import { useAutoplay } from './use-autoplay';
import styles from './page-risk-map.module.css';

/*
 * Page risk map: a contract read by a model and drawn one tile per page. Each tile is a tiny page
 * of grey lines; the clauses the review found are marked on it, where they sit on the page: a
 * green tick in the margin for a clause that matches the playbook, an amber highlight for one to
 * review, a red one for a clause that deviates.
 *
 * - The tiles are buttons in one tab stop (a roving tabindex): the arrow keys walk the grid, Home
 *   and End go to the ends of a row (with Ctrl, of the document), Enter or Space selects a page.
 *   The selected page wears a ring; the ring glides from page to page by a transform.
 * - A tooltip tells the page's clause ("§ 9.2 · p. 17", then their term → the playbook's), tied to
 *   its tile by a dotted leader. It shows the page under the pointer or the keyboard focus, else
 *   the selected one, on the tile's right or, near the right edge, on its left. It is placed once
 *   per change by a transform, so it slides from tile to tile without laying anything out.
 * - The legend filters: press Deviates, Review or Matched and every other page dims; press it
 *   again to see them all. The "to fix" pill takes the selection to the next page to fix.
 * - When the card first comes into view, a scan line runs down the grid: the tiles come up row by
 *   row behind it, then their marks. The marks grow from the margin (a scale), so nothing but
 *   transforms and opacity move.
 * - While on screen and untouched, a demo pointer hovers the pages to review, filters by the
 *   legend, and selects pages. Any real input hands the card to the reader.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

export type RiskStatus = 'deviates' | 'review' | 'matched';

/** A state to open in, for docs and tests: no scan, no demo. */
export type PageRiskMapState = {
  /** The legend's filter: only the pages with this verdict stay bright. */
  filter?: RiskStatus | null;
  /** The page under the pointer, from 1 to 42: the tooltip tells it, the ring stays put. */
  hover?: number | null;
};

type Clause = {
  section: string;
  name: string;
  status: RiskStatus;
  /** The first of the lines it covers, from 0 (top) to 6; a tick spans two, a highlight three. */
  line: number;
  /** For a clause to fix: their term, then the playbook's. For a matched one, the agreed term. */
  from?: string;
  to?: string;
  term?: string;
};

type Page = {
  /** Its lines' lengths, in px of the 35 px tile (25.5 is a full line); none for a table. */
  lines?: number[];
  clause?: Clause;
  /** A page of the schedules, drawn as a table. */
  schedule?: string;
};

/** A full line of text, in px at the card's own size. */
const FULL = 25.5;

/** The pages' lines, page by page, as a scan of the document would give them. */
const LINES = [
  '16 F F F F F 18',
  '18 F F F 19 F 20',
  'F F F F F F 17',
  'F 19 F 13 19 F 13',
  '18 F F F 16 F 14',
  'F F F F F F 15',
  '13 F 20 F F F 12',
  '17 18 20 F F F 15',
  'F F F F F 21 18',
  'F F F F F F 20',
  'F F 16 F F F 17',
  'F F F 18 F F 19',
  '19 16 16 F 16 F 16',
  'F F F F 20 F 18',
  'F F F 14 F F 18',
  'F F 15 F 16 F 17',
  'F F F F F F 14',
  'F 20 F F 16 F 21',
  'F F 17 F F F 15',
  'F 18 F F F 20 F',
  '14 F F F 15 F 18',
  '17 F 17 F 14 F 16',
  'F 16 F 18 F 18 20',
  '20 F 17 F 14 F 15',
  'F 20 F F 15 F 17',
  'F F F F F 20 19',
  '19 F F 15 F F 13',
  '20 F F F F F 16',
  '15 F 14 F F F 19',
  'F 20 F F F F 20',
  'F F F F F F 13',
  'F 19 F F F F 17',
  'F F F F F F 20',
  '16 F F F F F 19',
  '13 F F F F F 16',
  'F F F F F F 15',
  'F F F 19 F F 19',
  'F 19 F F F F 18',
  'F F 18 F F F 14',
].map((row) => row.split(' ').map((width) => (width === 'F' ? FULL : Number(width) - 0.5)));

/** The clauses the review found, by page number. */
const CLAUSES: Record<number, Clause> = {
  3: { section: '2.1', name: 'Term', status: 'matched', line: 3, term: '24 mo' },
  5: { section: '3.4', name: 'Fees', status: 'matched', line: 3, term: 'fixed' },
  6: { section: '4.2', name: 'Payment terms', status: 'matched', line: 1, term: 'net 30' },
  8: { section: '5.1', name: 'Confidentiality', status: 'matched', line: 4, term: '5 yr' },
  11: { section: '6.3', name: 'Data protection', status: 'matched', line: 2, term: 'DPA' },
  13: { section: '7.1', name: 'IP ownership', status: 'matched', line: 3, term: 'client' },
  14: { section: '7.4', name: 'Licence', status: 'matched', line: 2, term: 'non-excl.' },
  17: {
    section: '9.2',
    name: 'Liability cap',
    status: 'deviates',
    line: 2,
    from: '3 mo',
    to: '12 mo',
  },
  19: { section: '9.5', name: 'Insurance', status: 'matched', line: 3, term: '2× fees' },
  20: { section: '10.1', name: 'Warranty', status: 'matched', line: 2, term: '90 days' },
  23: {
    section: '12.1',
    name: 'Indemnity',
    status: 'review',
    line: 3,
    from: '1-way',
    to: 'mutual',
  },
  25: { section: '13.2', name: 'Termination', status: 'matched', line: 1, term: '30 days' },
  27: { section: '14.1', name: 'Force majeure', status: 'matched', line: 2, term: 'mutual' },
  29: { section: '16.2', name: 'Non-solicit', status: 'matched', line: 3, term: '12 mo' },
  31: { section: '17.1', name: 'Assignment', status: 'matched', line: 3, term: 'consent' },
  33: {
    section: '15.3',
    name: 'Auto-renewal',
    status: 'review',
    line: 1,
    from: '12 mo',
    to: '1 mo',
  },
  36: { section: '19.3', name: 'Notices', status: 'matched', line: 3, term: 'email' },
  37: { section: '20.1', name: 'Governing law', status: 'matched', line: 4, term: 'agreed' },
};

/** The highlighted lines' lengths, page by page: what the review marked. */
const HIGHLIGHTS: Record<number, number[]> = {
  17: [FULL, FULL, FULL],
  23: [18.5, FULL, 18.5],
  33: [FULL, FULL, FULL],
};

const SCHEDULES = ['Schedule A', 'Schedule B', 'Schedule C'];

const PAGES: Page[] = Array.from({ length: 42 }, (_, index) => {
  const number = index + 1;
  if (number > LINES.length) return { schedule: SCHEDULES[number - LINES.length - 1] };
  const lines = [...LINES[index]];
  const clause = CLAUSES[number];
  const marked = HIGHLIGHTS[number];
  if (clause && marked) lines.splice(clause.line, marked.length, ...marked);
  return { lines, clause };
});

const COLUMNS = 7;
const STATUSES: RiskStatus[] = ['deviates', 'review', 'matched'];
const LEGEND: Record<RiskStatus, string> = {
  deviates: 'Deviates',
  review: 'Review',
  matched: 'Matched',
};
const COUNTS = Object.fromEntries(
  STATUSES.map((status) => [
    status,
    Object.values(CLAUSES).filter((clause) => clause.status === status).length,
  ]),
) as Record<RiskStatus, number>;
/** The pages to fix, in order: what the pill steps through. */
const TO_FIX = PAGES.flatMap((page, index) =>
  page.clause && page.clause.status !== 'matched' ? [index] : [],
);

/** How long the scan takes to sweep the grid and settle its marks. */
const SCAN = 1350;

/** What a screen reader hears for a page. */
function describe(page: Page, index: number) {
  const { clause, schedule } = page;
  if (schedule) return `Page ${index + 1}, ${schedule}, a table`;
  if (!clause) return `Page ${index + 1}, no clauses`;
  const head = `Page ${index + 1}, § ${clause.section} ${clause.name}`;
  if (clause.status === 'matched') return `${head}, matched: ${clause.term}`;
  const verdict = clause.status === 'deviates' ? 'deviates' : 'to review';
  return `${head}, ${verdict}: ${clause.from} in their draft, ${clause.to} in the playbook`;
}

export function PageRiskMap({
  title = 'Risk by page',
  document = 'Larkspur MSA v4',
  defaultPage = 17,
  onSelectPage,
  autoplay = true,
  colorScheme,
  defaultState,
  className = '',
}: {
  /** The card's title. */
  title?: string;
  /** The document under review, under the title. */
  document?: string;
  /** The page selected at first, from 1 to 42; null for none. */
  defaultPage?: number | null;
  /** Called with the page number when the reader selects a page, or null when they clear it. */
  onSelectPage?: (page: number | null) => void;
  /** While on screen and untouched, a demo pointer hovers, filters and selects pages. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Opens in a given filter and hover, with no scan; it also turns the demo off. */
  defaultState?: PageRiskMapState;
  className?: string;
}) {
  const { ref: root, playing, visible, take } = useAutoplay<HTMLDivElement>(6000);
  const start = defaultPage && defaultPage >= 1 && defaultPage <= 42 ? defaultPage - 1 : null;
  const [selected, setSelected] = useState<number | null>(start);
  /** The page under the reader's pointer (or the demo's), and the one with the keyboard focus. */
  const fixed = defaultState !== undefined;
  const toIndex = (number?: number | null) =>
    number && number >= 1 && number <= 42 ? number - 1 : null;
  const [hovered, setHovered] = useState<number | null>(() => toIndex(defaultState?.hover));
  const [focused, setFocused] = useState<number | null>(null);
  /** Where the grid's single tab stop is. */
  const [current, setCurrent] = useState(start ?? 0);
  const [filter, setFilter] = useState<RiskStatus | null>(defaultState?.filter ?? null);
  /** 'idle' shows everything; 'waiting' hides the tiles until the card is seen; then the scan. */
  const [scan, setScan] = useState<'idle' | 'waiting' | 'scanning'>('idle');
  /** The demo pointer's press, on a page or a legend entry, for a beat. */
  const [pressed, setPressed] = useState<string | null>(null);
  const [message, setMessage] = useState('');

  const grid = useRef<HTMLDivElement>(null);
  const tiles = useRef<(HTMLButtonElement | null)[]>([]);
  const ring = useRef<HTMLSpanElement>(null);
  const tip = useRef<HTMLDivElement>(null);
  const leader = useRef<HTMLSpanElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const legend = useRef<Record<string, HTMLButtonElement | null>>({});
  const scanTimer = useRef(0);

  /** The page the tooltip tells: the one pointed at, else the focused one, else the selection. */
  const active = hovered ?? focused ?? selected;
  const activePage = active === null ? null : PAGES[active];
  const scanned = scan === 'idle';

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

  // The scan waits for the card to be seen, unless motion is reduced. Read before the first paint,
  // so the tiles never flash in and out.
  useLayoutEffect(() => {
    if (fixed) return;
    if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) setScan('waiting');
  }, [fixed]);
  const runScan = useEffectEvent(() => {
    setScan('scanning');
    window.clearTimeout(scanTimer.current);
    scanTimer.current = window.setTimeout(() => setScan('idle'), SCAN);
  });
  useEffect(() => {
    if (visible && scan === 'waiting') runScan();
  }, [visible, scan]);
  useEffect(() => () => window.clearTimeout(scanTimer.current), []);

  // Places the ring on the selected tile, and the tooltip and its leader by the active one, by
  // transforms. A box that was hidden jumps to its place; a shown one slides there.
  const place = useEffectEvent(() => {
    const box = grid.current;
    if (!box) return;
    const mark = ring.current;
    const chosen = selected === null ? null : tiles.current[selected];
    if (mark) {
      if (chosen) {
        const moving = mark.dataset.shown === 'true';
        if (!moving) mark.style.transition = 'none';
        mark.style.transform = `translate(${chosen.offsetLeft}px, ${chosen.offsetTop}px)`;
        mark.style.width = `${chosen.offsetWidth}px`;
        mark.style.height = `${chosen.offsetHeight}px`;
        if (!moving) {
          void mark.offsetWidth;
          mark.style.transition = '';
        }
      }
      mark.dataset.shown = String(Boolean(chosen));
    }
    const card = tip.current;
    const line = leader.current;
    const tile = active === null ? null : tiles.current[active];
    if (!card || !line) return;
    if (!tile) {
      card.dataset.shown = 'false';
      line.dataset.shown = 'false';
      return;
    }
    // The gap between the tile and the tooltip is 21 px at the card's own size.
    const unit = tile.offsetWidth / 35.43;
    const gap = 21 * unit;
    const ringOut = 3.75 * unit;
    const right = tile.offsetLeft + tile.offsetWidth + gap;
    const fits = right + card.offsetWidth <= box.offsetWidth + 4 * unit;
    const x = fits ? right : tile.offsetLeft - gap - card.offsetWidth;
    const y = tile.offsetTop;
    const from = fits ? tile.offsetLeft + tile.offsetWidth + ringOut : x + card.offsetWidth;
    const to = fits ? x : tile.offsetLeft - ringOut;
    const moving = card.dataset.shown === 'true';
    for (const element of [card, line]) if (!moving) element.style.transition = 'none';
    card.style.transform = `translate(${x}px, ${y}px)`;
    card.style.height = `${tile.offsetHeight}px`;
    line.style.transform = `translate(${Math.min(from, to)}px, ${y + tile.offsetHeight / 2}px)`;
    line.style.width = `${Math.abs(to - from)}px`;
    if (!moving) {
      void card.offsetWidth;
      card.style.transition = '';
      line.style.transition = '';
    }
    card.dataset.shown = 'true';
    line.dataset.shown = 'true';
  });
  useLayoutEffect(() => place(), [active, selected, activePage]);
  useEffect(() => {
    const box = grid.current;
    if (!box) return;
    const resize = new ResizeObserver(() => place());
    resize.observe(box);
    return () => resize.disconnect();
  }, []);

  const select = (index: number | null, quiet = false) => {
    setSelected(index);
    if (index !== null) setCurrent(index);
    onSelectPage?.(index === null ? null : index + 1);
    if (!quiet)
      announce(index === null ? 'No page selected' : `Selected: ${describe(PAGES[index], index)}`);
  };

  const toggleFilter = (status: RiskStatus) => {
    const next = filter === status ? null : status;
    setFilter(next);
    announce(
      next
        ? `Showing ${LEGEND[next].toLowerCase()} only: ${COUNTS[next]} ${COUNTS[next] === 1 ? 'page' : 'pages'}`
        : 'Showing every page',
    );
  };

  const nextToFix = () => {
    const after = TO_FIX.find((index) => selected === null || index > selected) ?? TO_FIX[0];
    select(after);
    tiles.current[after]?.focus({ preventScroll: true });
  };

  const onGridKey = (event: ReactKeyboardEvent) => {
    const from = current;
    const row = Math.floor(from / COLUMNS);
    let to = from;
    if (event.key === 'ArrowRight') to = Math.min(41, from + 1);
    else if (event.key === 'ArrowLeft') to = Math.max(0, from - 1);
    else if (event.key === 'ArrowDown') to = from + COLUMNS <= 41 ? from + COLUMNS : from;
    else if (event.key === 'ArrowUp') to = from - COLUMNS >= 0 ? from - COLUMNS : from;
    else if (event.key === 'Home') to = event.ctrlKey ? 0 : row * COLUMNS;
    else if (event.key === 'End') to = event.ctrlKey ? 41 : row * COLUMNS + COLUMNS - 1;
    else if (event.key === 'Escape' && filter) {
      event.preventDefault();
      setFilter(null);
      announce('Showing every page');
      return;
    } else return;
    event.preventDefault();
    setCurrent(to);
    tiles.current[to]?.focus({ preventScroll: true });
  };

  // The demo: hover the pages to review, filter by the legend, select a page and come back.
  const demoHover = useEffectEvent((index: number | null) => setHovered(index));
  const demoPress = useEffectEvent((what: string) => {
    setPressed(what);
    window.setTimeout(() => setPressed((now) => (now === what ? null : now)), 160);
    if (what.startsWith('page-')) select(Number(what.slice(5)), true);
    else setFilter((now) => (now === what ? null : (what as RiskStatus)));
  });
  const demoReset = useEffectEvent((rescan: boolean) => {
    setFilter(null);
    setHovered(null);
    select(start, true);
    if (rescan) runScan();
  });
  const scanning = useRef(false);
  scanning.current = scan !== 'idle';
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!autoplay || fixed || !playing || !stage || !arrow) return;
    /** The middle of an element, in the stage's own px. */
    const at = (element: HTMLElement | null | undefined, dx = 0.5, dy = 0.55): Point => {
      const frame = stage.getBoundingClientRect();
      const scale = frame.width / stage.offsetWidth || 1;
      const rect = element?.getBoundingClientRect() ?? frame;
      return {
        x: (rect.left + rect.width * dx - frame.left) / scale,
        y: (rect.top + rect.height * dy - frame.top) / scale,
      };
    };
    const tile = (page: number) => tiles.current[page - 1];
    const stop = runDemo(stage, arrow, async ({ wait, glide, glyph }) => {
      let first = true;
      for (;;) {
        if (!first) {
          demoReset(true);
          await wait(SCAN + 300);
        } else {
          while (scanning.current) await wait(120);
          await wait(500);
        }
        first = false;
        glyph('arrow');
        await glide(at(tile(23)), 760);
        demoHover(22);
        await wait(1300);
        await glide(at(tile(33)), 620);
        demoHover(32);
        await wait(1300);
        demoHover(null);
        await glide(at(legend.current.review, 0.35), 700);
        glyph('hand');
        await wait(220);
        demoPress('review');
        await wait(1500);
        await glide(at(legend.current.deviates, 0.35), 520);
        await wait(200);
        demoPress('deviates');
        await wait(1500);
        await wait(80);
        demoPress('deviates');
        await wait(600);
        await glide(at(tile(23)), 640);
        demoHover(22);
        await wait(250);
        demoPress('page-22');
        await wait(1400);
        demoHover(null);
        await glide(at(tile(17)), 560);
        demoHover(16);
        await wait(250);
        demoPress('page-16');
        await wait(900);
        demoHover(null);
        glyph('arrow');
        await glide(at(stage, 0.86, 0.94), 900);
        await wait(2400);
      }
    });
    return () => {
      stop();
      demoReset(false);
    };
  }, [autoplay, fixed, playing, root]);

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="stage"
      data-color-scheme={colorScheme}
      data-scan={scan}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onPointerMove={(event) => {
        if (event.pointerType !== 'touch') take();
      }}
    >
      <section className={styles.card} data-part="card" aria-label={`${title}, ${document}`}>
        <header className={styles.header} data-part="header">
          <span className={styles.icon} data-part="icon" aria-hidden="true">
            <span />
            <span />
            <span />
            <span />
          </span>
          <div className={styles.heading}>
            <h3 className={styles.title} data-part="title">
              {title}
            </h3>
            <p className={styles.document} data-part="document">
              {document}
            </p>
          </div>
          <button
            type="button"
            className={styles.count}
            data-part="count"
            onClick={nextToFix}
            aria-label={`${TO_FIX.length} to fix: select the next one`}
          >
            {TO_FIX.length} to fix
          </button>
        </header>

        <div className={styles.panel} data-part="panel">
          <p className={styles.meta} data-part="meta">
            <span>
              {PAGES.length} pages · {Object.keys(CLAUSES).length} clauses
            </span>
            <span className={styles.scale}>1 tile = 1 page</span>
          </p>

          <div className={styles.map} data-part="map">
            <div
              ref={grid}
              className={styles.grid}
              data-part="grid"
              role="group"
              aria-label={`Pages of ${document}: arrow keys to move, Enter to select`}
              onKeyDown={onGridKey}
              onPointerLeave={() => setHovered(null)}
            >
              {PAGES.map((page, index) => {
                const status = page.clause?.status;
                const dim = filter !== null && status !== filter;
                return (
                  <button
                    key={index}
                    ref={(element) => {
                      tiles.current[index] = element;
                    }}
                    type="button"
                    className={styles.tile}
                    data-part="tile"
                    data-status={status ?? 'none'}
                    data-dim={dim || undefined}
                    data-selected={selected === index || undefined}
                    data-pressed={pressed === `page-${index}` || undefined}
                    tabIndex={index === current ? 0 : -1}
                    aria-pressed={selected === index}
                    aria-label={describe(page, index)}
                    style={
                      {
                        '--row': Math.floor(index / COLUMNS),
                        '--col': index % COLUMNS,
                      } as CSSProperties
                    }
                    onClick={() => select(selected === index ? null : index)}
                    onPointerEnter={(event) => {
                      if (event.pointerType !== 'touch') setHovered(index);
                    }}
                    onFocus={() => {
                      setCurrent(index);
                      setFocused(index);
                    }}
                    onBlur={() => setFocused((now) => (now === index ? null : now))}
                  >
                    <PageDrawing page={page} first={index === 0} />
                  </button>
                );
              })}
              <span ref={ring} className={styles.ring} data-part="ring" aria-hidden="true" />
              <span ref={leader} className={styles.leader} data-part="leader" aria-hidden="true" />
              <div
                ref={tip}
                className={styles.tooltip}
                data-part="tooltip"
                data-status={activePage?.clause?.status ?? 'none'}
                aria-hidden="true"
              >
                {activePage && <TipText page={activePage} index={active ?? 0} />}
              </div>
              <span className={styles.beam} data-part="scan" aria-hidden="true" />
            </div>
          </div>

          <div className={styles.legend} data-part="legend" role="group" aria-label="Filter pages">
            {STATUSES.map((status) => (
              <button
                key={status}
                ref={(element) => {
                  legend.current[status] = element;
                }}
                type="button"
                className={styles.key}
                data-part="legend-item"
                data-status={status}
                data-off={(filter !== null && filter !== status) || undefined}
                data-pressed={pressed === status || undefined}
                aria-pressed={filter === status}
                onClick={() => toggleFilter(status)}
              >
                <span className={styles.swatch} aria-hidden="true" />
                <span className={styles.keyLabel}>{LEGEND[status]}</span>
                <span className={styles.keyCount}>{COUNTS[status]}</span>
              </button>
            ))}
          </div>
        </div>
      </section>

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

/** A page in miniature: its lines, or a table, and the clause's mark where it sits. */
function PageDrawing({ page, first }: { page: Page; first: boolean }) {
  const { lines, clause } = page;
  if (!lines)
    return (
      <span className={styles.table} aria-hidden="true">
        {[0, 1, 2, 3, 4].map((row) => (
          <span key={row} className={styles.rule} style={{ '--k': row } as CSSProperties} />
        ))}
        <span className={styles.column} />
      </span>
    );
  const highlighted = clause && clause.status !== 'matched';
  return (
    <span className={styles.page} aria-hidden="true">
      {clause && (
        <span
          className={highlighted ? styles.band : styles.tick}
          data-part={highlighted ? 'highlight' : 'tick'}
          style={{ '--k': clause.line } as CSSProperties}
        />
      )}
      {lines.map((width, k) => {
        const marked = highlighted && k >= clause.line && k < clause.line + 3;
        return (
          <span
            key={k}
            className={styles.line}
            data-head={(first && k === 0) || undefined}
            data-marked={marked || undefined}
            style={{ '--k': k, '--w': width } as CSSProperties}
          />
        );
      })}
    </span>
  );
}

/** The tooltip's two lines: where the clause is, then what it says against the playbook. */
function TipText({ page, index }: { page: Page; index: number }) {
  const { clause, schedule } = page;
  const where = clause ? `§ ${clause.section} · p. ${index + 1}` : `p. ${index + 1}`;
  let what: ReactNode = 'no clauses';
  if (schedule) what = schedule;
  else if (clause?.status === 'matched') what = `${clause.term} ✓`;
  else if (clause)
    what = (
      <>
        {clause.from}
        <span className={styles.becomes}>→</span>
        {clause.to}
      </>
    );
  return (
    <>
      <span className={styles.where}>{where}</span>
      <span className={styles.what}>{what}</span>
    </>
  );
}
```

### page-risk-map/page-risk-map.module.css

```css
/* Page risk map (page-risk-map.tsx): a quiet stage with one card on it. The card is drawn in a
   unit of its own, --u: 1px at its own size (326 px wide), larger on a wide stage and a little
   smaller on a narrow one, so every length below is a multiple of 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).

   Motion is transforms and opacity only: the ring, the tooltip and its leader are placed by
   transforms, the scan's tiles fade and rise, the marks grow from the margin. */

.root {
  /* The stage, the card and its edge. */
  --stage: #ecebe8;
  --card: #fff;
  --card-edge: rgb(0 0 0 / 0.07);
  --card-shadow: rgb(0 0 0 / 0.06);
  /* The round icon, its bright square and the others. */
  --icon-bg: #f0f0ef;
  --icon-hi: #3b3b3b;
  --icon: #a3a3a3;
  /* Words: the title and the numbers, the quieter text, the quietest. */
  --ink: #161616;
  --muted: #6f6f6f;
  --faint: #a6a6a6;
  /* The pill. */
  --pill: #f2f2f1;
  /* The inner panel, and the tiles on it. */
  --panel: #f7f7f6;
  --panel-edge: #ebebea;
  --tile: #fff;
  --tile-edge: rgb(0 0 0 / 0.06);
  --tile-hover: #fbfbfb;
  --line: #e2e2e1;
  --line-head: #b4b4b3;
  /* The ring round the selected page, the leader, the tooltip. */
  --ring: #2b2b2b;
  --leader: #9a9a9a;
  --tip: rgb(255 255 255 / 0.86);
  --tip-edge: rgb(0 0 0 / 0.1);
  --tip-shadow: rgb(0 0 0 / 0.1);
  --tip-ink: #161616;
  --tip-muted: #7a7a7a;
  /* The verdicts: the legend's swatch, the marks on a page, a highlight's wash. */
  --red: #e5484d;
  --red-mark: #e0464c;
  --red-wash: rgb(229 72 77 / 0.13);
  --amber: #f0a020;
  --amber-mark: #df8f12;
  --amber-wash: rgb(240 160 32 / 0.16);
  --green: #2bbd7e;
  --green-mark: #2aa86f;
  --scan: rgb(0 0 0 / 0.06);
  /* The same on both schemes: the focus ring, and the demo's pointer, which looks like the
     system's. */
  --focus: #387bff;
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  position: relative;
  container-type: inline-size;
  display: grid;
  place-items: center;
  width: 100%;
  padding: 32px 12px;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--stage);
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --stage: light-dark(#ecebe8, #1e1e1e);
    --card: light-dark(#fff, #111);
    --card-edge: light-dark(rgb(0 0 0 / 0.07), rgb(255 255 255 / 0.05));
    --card-shadow: light-dark(rgb(0 0 0 / 0.06), transparent);
    --icon-bg: light-dark(#f0f0ef, #2c2c2c);
    --icon-hi: light-dark(#3b3b3b, #c8c8c8);
    --icon: light-dark(#a3a3a3, #808080);
    --ink: light-dark(#161616, #f2f2f2);
    --muted: light-dark(#6f6f6f, #919191);
    --faint: light-dark(#a6a6a6, #5e5e5e);
    --pill: light-dark(#f2f2f1, #1e1e1e);
    --panel: light-dark(#f7f7f6, #0d0d0d);
    --panel-edge: light-dark(#ebebea, #1c1c1c);
    --tile: light-dark(#fff, #1a1a1a);
    --tile-edge: light-dark(rgb(0 0 0 / 0.06), transparent);
    --tile-hover: light-dark(#fbfbfb, #212121);
    --line: light-dark(#e2e2e1, #313131);
    --line-head: light-dark(#b4b4b3, #5f5f5f);
    --ring: light-dark(#2b2b2b, #d0d0d0);
    --leader: light-dark(#9a9a9a, #7a7a7a);
    --tip: light-dark(rgb(255 255 255 / 0.86), rgb(44 44 44 / 0.88));
    --tip-edge: light-dark(rgb(0 0 0 / 0.1), rgb(255 255 255 / 0.09));
    --tip-shadow: light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.3));
    --tip-ink: light-dark(#161616, #f2f2f2);
    --tip-muted: light-dark(#7a7a7a, #939393);
    --red: light-dark(#e5484d, #f6535a);
    --red-mark: light-dark(#e0464c, #cf4e55);
    --red-wash: light-dark(rgb(229 72 77 / 0.13), rgb(246 83 90 / 0.16));
    --amber: light-dark(#f0a020, #f6a226);
    --amber-mark: light-dark(#df8f12, #d69228);
    --amber-wash: light-dark(rgb(240 160 32 / 0.16), rgb(246 162 38 / 0.16));
    --green: light-dark(#2bbd7e, #3ed68c);
    --green-mark: light-dark(#2aa86f, #38a36a);
    --scan: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.06));
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

.root button {
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.root button:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* The card: 326 × 450 at its own size. */
.card {
  --u: clamp(0.66px, 100cqw / 326, 1.5px);
  width: calc(326 * var(--u));
  padding: 0 calc(10.5 * var(--u)) calc(11 * var(--u));
  border: 1px solid var(--card-edge);
  border-radius: calc(12 * var(--u));
  background: var(--card);
  box-shadow: 0 calc(8 * var(--u)) calc(24 * var(--u)) var(--card-shadow);
  color: var(--ink);
}

.header {
  display: flex;
  align-items: flex-start;
  height: calc(55.5 * var(--u));
  padding-top: calc(8.1 * var(--u));
}
.icon {
  display: grid;
  flex: none;
  grid-template-columns: repeat(2, calc(4.5 * var(--u)));
  grid-template-rows: calc(5.5 * var(--u)) calc(3.5 * var(--u));
  gap: calc(2 * var(--u));
  place-content: center;
  width: calc(28 * var(--u));
  height: calc(28 * var(--u));
  margin-top: calc(2.9 * var(--u));
  border-radius: 50%;
  background: var(--icon-bg);
}
.icon > span {
  border-radius: calc(1.5 * var(--u));
  background: var(--icon);
}
.icon > span:first-child {
  background: var(--icon-hi);
}
.heading {
  flex: 1;
  min-width: 0;
  margin-left: calc(12.5 * var(--u));
}
.title {
  margin: 0;
  font-size: calc(14 * var(--u));
  font-weight: 450;
  line-height: calc(18 * var(--u));
  letter-spacing: -0.02em;
}
.document {
  margin: calc(1.3 * var(--u)) 0 0;
  overflow: hidden;
  color: var(--muted);
  font-size: calc(12 * var(--u));
  line-height: calc(16 * var(--u));
  white-space: nowrap;
  text-overflow: ellipsis;
}
.root .count {
  flex: none;
  height: calc(20 * var(--u));
  margin: calc(7 * var(--u)) 0 0 calc(8 * var(--u));
  padding: 0 calc(7.25 * var(--u));
  border-radius: 999px;
  background: var(--pill);
  color: var(--muted);
  font-size: calc(12 * var(--u));
  line-height: calc(20 * var(--u));
  white-space: nowrap;
  transition:
    color 160ms ease,
    transform 160ms ease;
}
.root .count:hover {
  color: var(--ink);
}
.root .count:active {
  transform: scale(0.96);
}

.panel {
  padding: calc(15.4 * var(--u)) calc(14.5 * var(--u)) calc(15.4 * var(--u));
  border: 1px solid var(--panel-edge);
  border-radius: calc(10 * var(--u));
  background: var(--panel);
}
.meta {
  display: flex;
  justify-content: space-between;
  gap: calc(8 * var(--u));
  margin: 0;
  color: var(--muted);
  font-size: calc(12 * var(--u));
  line-height: calc(16 * var(--u));
  white-space: nowrap;
}
.scale {
  color: var(--faint);
}

.map {
  margin-top: calc(11.6 * var(--u));
}
.grid {
  position: relative;
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  gap: calc(5.5 * var(--u)) calc(4 * var(--u));
}

/* A page. Its drawing is in px of the card's unit: lines 2 high, 5 apart, 5 in from the left. */
.root .tile {
  position: relative;
  display: block;
  aspect-ratio: 35.43 / 44.75;
  border-radius: calc(3 * var(--u));
  background: var(--tile);
  box-shadow: inset 0 0 0 1px var(--tile-edge);
  transition:
    opacity 220ms ease,
    background-color 140ms ease,
    transform 160ms ease;
}
.root .tile:hover {
  background: var(--tile-hover);
}
.root .tile[data-pressed],
.root .tile:active {
  transform: scale(0.95);
}
.root .tile[data-dim] {
  opacity: 0.28;
}
.root .tile:focus-visible {
  outline-offset: calc(2 * var(--u) + 2px);
}
.page,
.table {
  position: absolute;
  inset: 0;
  pointer-events: none;
}
.line {
  position: absolute;
  top: calc((5.5 + var(--k) * 5) * var(--u));
  left: calc(5 * var(--u));
  width: calc(var(--w) * var(--u));
  height: calc(2 * var(--u));
  border-radius: calc(1 * var(--u));
  background: var(--line);
}
.line[data-head] {
  background: var(--line-head);
}
.tile[data-status='deviates'] .line[data-marked] {
  background: var(--red-mark);
}
.tile[data-status='review'] .line[data-marked] {
  background: var(--amber-mark);
}
/* A matched clause: a tick in the margin, over two lines. */
.tick {
  position: absolute;
  top: calc((4.5 + var(--k) * 5) * var(--u));
  left: calc(1 * var(--u));
  width: calc(2 * var(--u));
  height: calc(9 * var(--u));
  border-radius: calc(1 * var(--u));
  background: var(--green-mark);
}
/* A clause to fix: a wash over three lines, with a bar in the margin. */
.band {
  position: absolute;
  top: calc((2.5 + var(--k) * 5) * var(--u));
  right: calc(2.25 * var(--u));
  left: calc(1 * var(--u));
  height: calc(15 * var(--u));
  border-radius: calc(1.5 * var(--u));
  border-left: calc(1.75 * var(--u)) solid;
}
.tile[data-status='deviates'] .band {
  border-left-color: var(--red);
  background: var(--red-wash);
}
.tile[data-status='review'] .band {
  border-left-color: var(--amber);
  background: var(--amber-wash);
}
/* The schedules: a table of five rules and a column. */
.rule {
  position: absolute;
  top: calc((8 + var(--k) * 6) * var(--u));
  left: calc(4.5 * var(--u));
  width: calc(25.5 * var(--u));
  height: calc(1 * var(--u));
  background: var(--line);
}
.column {
  position: absolute;
  top: calc(8 * var(--u));
  left: calc(16 * var(--u));
  width: calc(1 * var(--u));
  height: calc(25 * var(--u));
  background: var(--line);
}

/* The selected page's ring, 2 px off the tile. */
.ring {
  position: absolute;
  top: 0;
  left: 0;
  pointer-events: none;
  opacity: 0;
  transition:
    transform 260ms cubic-bezier(0.2, 0.8, 0.2, 1),
    opacity 180ms ease;
}
.ring::before {
  content: '';
  position: absolute;
  inset: calc(-3.75 * var(--u));
  border: calc(1.5 * var(--u)) solid var(--ring);
  border-radius: calc(5.5 * var(--u));
}
.ring[data-shown='true'] {
  opacity: 1;
}

/* The tooltip and its dotted leader. */
.leader {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 2;
  height: calc(1 * var(--u));
  margin-top: calc(-0.5 * var(--u));
  pointer-events: none;
  opacity: 0;
  background: repeating-linear-gradient(
    to right,
    var(--leader) 0 calc(2 * var(--u)),
    transparent 0 calc(4 * var(--u))
  );
  transition:
    transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1),
    opacity 160ms ease;
}
.tooltip {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 3;
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: 0 calc(8 * var(--u)) 0 calc(7.5 * var(--u));
  border: 1px solid var(--tip-edge);
  border-radius: calc(4 * var(--u));
  background: var(--tip);
  box-shadow: 0 calc(6 * var(--u)) calc(18 * var(--u)) var(--tip-shadow);
  color: var(--tip-ink);
  font-family: JetBrains, 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
  font-size: calc(11.67 * var(--u));
  line-height: calc(18 * var(--u));
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  transition:
    transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1),
    opacity 160ms ease;
}
.tooltip[data-shown='true'],
.leader[data-shown='true'] {
  opacity: 1;
}
.where {
  color: var(--tip-muted);
  font-weight: 300;
}
.what {
  font-weight: 500;
}
.becomes {
  display: inline-block;
  width: calc(32 * var(--u));
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  font-weight: 400;
  letter-spacing: 0;
  text-align: center;
}
.tooltip[data-status='matched'] .what {
  color: var(--green);
}

.legend {
  display: flex;
  flex-wrap: wrap;
  gap: 0 calc(15 * var(--u));
  margin-top: calc(11.6 * var(--u));
}
.root .key {
  display: inline-flex;
  align-items: center;
  height: calc(16 * var(--u));
  color: var(--muted);
  font-size: calc(12 * var(--u));
  line-height: calc(16 * var(--u));
  transition:
    opacity 180ms ease,
    transform 160ms ease;
}
.root .key[data-off] {
  opacity: 0.4;
}
.root .key[data-pressed],
.root .key:active {
  transform: scale(0.95);
}
.root .key:hover .keyLabel {
  color: var(--ink);
}
.swatch {
  width: calc(8 * var(--u));
  height: calc(8 * var(--u));
  margin-right: calc(6 * var(--u));
  border-radius: calc(2 * var(--u));
}
.key[data-status='deviates'] .swatch {
  background: var(--red);
}
.key[data-status='review'] .swatch {
  background: var(--amber);
}
.key[data-status='matched'] .swatch {
  background: var(--green);
}
.keyLabel {
  transition: color 140ms ease;
}
.keyCount {
  margin-left: calc(6.5 * var(--u));
  font-size: calc(12.5 * var(--u));
  color: var(--ink);
  font-family: JetBrains, 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
  font-weight: 500;
}

/* The scan: a soft line runs down the grid; the tiles rise behind it, row by row, then their
   marks grow from the margin. Until the card is seen, the tiles wait out of sight. */
.beam {
  position: absolute;
  top: 0;
  right: calc(-6 * var(--u));
  left: calc(-6 * var(--u));
  z-index: 4;
  height: calc(18 * var(--u));
  margin-top: calc(-9 * var(--u));
  pointer-events: none;
  opacity: 0;
  background: linear-gradient(to bottom, transparent, var(--scan) 60%, transparent);
}
.beam::after {
  content: '';
  position: absolute;
  right: 0;
  bottom: calc(6 * var(--u));
  left: 0;
  height: 1px;
  background: linear-gradient(to right, transparent, var(--leader), transparent);
  opacity: 0.6;
}
.root[data-scan='waiting'] .tile {
  opacity: 0;
}
.root[data-scan='scanning'] .tile {
  animation: tile-in 340ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
  animation-delay: calc(var(--row) * 95ms + var(--col) * 14ms);
}
.root[data-scan='scanning'] .tick,
.root[data-scan='scanning'] .band {
  transform-origin: left center;
  animation: mark-in 260ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
  animation-delay: calc(var(--row) * 95ms + var(--col) * 14ms + 240ms);
}
.root[data-scan='scanning'] .line[data-marked] {
  animation: fade-in 260ms ease both;
  animation-delay: calc(var(--row) * 95ms + var(--col) * 14ms + 240ms);
}
.root[data-scan='scanning'] .beam {
  animation: scan 760ms cubic-bezier(0.4, 0, 0.6, 1) both;
}
.root[data-scan='waiting'] .ring,
.root[data-scan='waiting'] .tooltip,
.root[data-scan='waiting'] .leader,
.root[data-scan='scanning'] .ring,
.root[data-scan='scanning'] .tooltip,
.root[data-scan='scanning'] .leader {
  visibility: hidden;
}
@keyframes tile-in {
  from {
    opacity: 0;
    transform: translateY(calc(4 * var(--u))) scale(0.94);
  }
}
@keyframes mark-in {
  from {
    opacity: 0;
    transform: scaleX(0.15);
  }
}
@keyframes fade-in {
  from {
    opacity: 0;
  }
}
@keyframes scan {
  0% {
    opacity: 0;
    transform: translateY(0);
  }
  12% {
    opacity: 1;
  }
  88% {
    opacity: 1;
  }
  100% {
    opacity: 0;
    transform: translateY(calc(300 * var(--u)));
  }
}

/* The demo pointer, over everything; one of its glyphs shows at a time. */
.pointerLayer {
  position: absolute;
  inset: 0;
  pointer-events: none;
}
.pointer {
  width: clamp(18px, 2.6cqi, 26px);
  height: clamp(18px, 2.6cqi, 26px);
}
.hand {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .arrow {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .hand {
  visibility: visible;
}

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

@media (prefers-reduced-motion: reduce) {
  .ring,
  .tooltip,
  .leader,
  .root .tile,
  .root .key,
  .root .count {
    transition: none;
  }
}
```

### page-risk-map/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 can be handed to whatever should move along with it. The arrow and the hand are HTML boxes
 * that swap by their visibility: a change of style inside an SVG would lay the SVG out again.
 */

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

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

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

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

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

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

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

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

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

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

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

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

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

/**
 * The pointer's box: it moves, on a layer of its own. Its size comes from its class, its colours
 * from the stage's tokens (page-risk-map.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 what it presses. 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>
  );
}
```

### page-risk-map/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, visible, reduced, playing: visible && !reduced && !taken };
}
```