Page risk map
NewA contract drawn one tile per page, with each clause the review found marked where it sits: matched, to review or deviating.
page-risk-map/page-risk-map.tsxRisk by page
Larkspur MSA v4
The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
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.
You can add this component with the shadcn CLI or by hand:
Into components/page-risk-map of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/page-risk-map.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: 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
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)} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
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.
- 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.
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | 'Risk by page' | The card’s title. |
document | string | 'Larkspur MSA v4' | The document under review, under the title; it also names the grid for screen readers. |
defaultPage | number | null | 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 | 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. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// 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. */* 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;// 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`. */// 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(- 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.