# Risk scale

> Every open loan as a dot on one health-factor track, with a repay suggestion for the weakest.

A card for lending positions. A track cut into four bands (liquidation, at risk, watch, safe) carries a dot for each loan, its coin on a stem above and its value and venue below. The weakest loan under the target wears a halo and a dark tooltip saying how much to repay to lift it back. Hover or focus a loan and the others fade while a card shows its debt, its collateral and the price that would liquidate it.

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

Category: Data.

## Anatomy

1. **Title**: Names the card and labels its section; the subtitle beside it says what the track measures.
2. **Legend**: One swatch per band with its range, written from the `thresholds`, so the legend never disagrees with the track.
3. **Band**: The track is four pills, one per band, placed on a log scale from 0.9 to 5; only the scale’s two ends are fully round.
4. **Repay tooltip**: Points at the selected loan and says what to repay to lift it to the target, rounded up to $10.
5. **Halo**: Rings the selected loan, by default the weakest under the target, in its band’s colour.
6. **Coin**: The coin borrowed, on a stem above the dot; the whole column is one button, so it is all hover area.
7. **Dot**: Sits on the track at the loan’s health factor, in its band’s colour, with a ring to part it from the band.
8. **Value and venue**: The health factor and where the loan is held. A label that would hit its neighbour drops to a second or third row.

## When to use

- A lending overview where every open loan has a health factor and the question is which one is closest to liquidation.
- Up to about ten positions on one ordered measure, read against fixed thresholds: liquidation, at risk, watch, safe.
- Values that crowd at the low end, where the difference between 1.05 and 1.2 matters more than 3 and 4: the log scale spreads them out.
- When one action follows from the reading: the tooltip names the repayment that lifts the weakest loan to the `target`.
- Dashboards where hovering a loan should answer the next question (its debt, its collateral, its liquidation price) without leaving the card.

## When not to use

- Dozens of positions: the labels run out of rows. Use a sortable table with a health column.
- A single loan’s health. A gauge or a meter with its bands reads faster.
- Health over time. Use a line chart with the thresholds drawn as bands.
- Measures without meaningful thresholds or a direction of danger: the four coloured bands would invent a judgement.
- Touch-only screens where the hover card is the point: on touch, only the tap’s selection and its tooltip remain.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/risk-scale.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 { RiskScale, type RiskPosition } from './risk-scale';

const positions: RiskPosition[] = [
  { id: 'a', value: 1.08, venue: 'Halden', asset: 'dln', debt: 13700 },
  { id: 'b', value: 1.34, venue: 'Corvo', asset: 'tsl', debt: 2300 },
  { id: 'c', value: 2.4, venue: 'Quill', asset: 'vlr', debt: 9000 },
];

export function LoansOverview() {
  return (
    <RiskScale
      positions={positions}
      target={1.5}
      autoplay={false}
      onSelect={(position) => console.log('Open', position.id)}
    />
  );
}
```

## States

- **Weakest selected**: The default: the weakest loan under the target wears the halo and the repay tooltip.
- **Another loan selected**: Set by a click, Enter or `defaultSelected`; a loan at or above the target is called healthy.
- **Hovered**: The hover card shows the loan’s debt, collateral and liquidation price; the others fade back.
- **Liquidatable**: A loan under 1.0 sits in the red band, and its card says it is open to liquidation now.
- **Crowded labels**: Close values stack their labels into up to three rows, and the plot grows to fit them.
- **All healthy**: Nothing under the target: the lowest loan is selected and the tooltip says it is healthy.
- **Dark**: With `colorScheme="dark"` the bands turn wine, amber and pine, the coins pastel, the tooltip white.

## How it works

- The scale is logarithmic, from 0.9 to 5 by default: a step from 1.0 to 1.2 takes as much room as one from 3 to 3.6, so the crowded low end is spread out and a 4.29 still fits. Values past the ends sit at the ends.
- The track is four absolutely placed pills, one per band, each drawn by a ::before inset 1.25 px so the bands stand 2.5 px apart; only the scale’s two ends are fully round.
- Each loan is a zero-width button at its place on the scale, with its coin, stem, dot, halo and labels hanging from it, so the whole column is its hover area and one left: n% places it all.
- The labels are measured once laid out: left to right, each takes the first row where it clears the last label placed there by 10 px (up to three rows), and those at the ends are nudged back inside the card.
- The repay suggestion follows from health being collateral over debt: the debt that reaches the target is debt × value ÷ target, so the repayment is debt × (1 − value ÷ target), rounded up to $10.
- The liquidation price in the hover card is the collateral’s price × 1.0 ÷ health, and the drop it takes is 1 − 1 ÷ health: a loan at 1.86 liquidates after a 46.2% fall.
- The tooltip and the hover card move by transforms only, the card sliding from loan to loan in 260 ms; it is an overlay that takes no room in the layout, opening above the coins when the screen (and any frame that clips) has the room there, and under the labels otherwise. A card held open from the start (defaultHovered) has the room above reserved for it.
- The first time the card comes on screen, the loans rise 4 px into place and fade in, 40 ms apart from left to right, the tooltip with the first; under reduced motion they are simply there.
- While on screen and untouched, a demo pointer hovers the two weakest loans, selects the second (the halo and the tooltip move to it), visits a safe one and selects the weakest again. Any real input stops it.
- Every colour is a light-dark() token on the root. At night the bands become deep wine, amber and pine, the coins turn pastel and the tooltip turns white with dark words.

## API reference

- **positions** (`RiskPosition[]`, default `SAMPLE_POSITIONS`): The open loans, in any order: { id, value (health factor), venue, asset (‘dln’ | ‘tsl’ | ‘vlr’ | ‘orn’), network?, debt?, collateral?: { amount, asset, value } }. Debt prices the repay suggestion; collateral fills the hover card and its liquidation price.
- **thresholds** (`{ liquidation: number; risk: number; watch: number }`, default `{ liquidation: 1, risk: 1.2, watch: 1.5 }`): Where the bands meet; the legend is written from them.
- **target** (`number`, default `1.5`): The health the repay suggestion lifts the selected loan to.
- **range** (`[number, number]`, default `[0.9, 5]`): The ends of the logarithmic track; values beyond them sit at its ends.
- **title** (`string`, default `'Liquidation risk'`): The card’s title.
- **subtitle** (`string`, default `'Health factor of each open position'`): The muted line beside the title.
- **selected** (`string`): The selected loan’s id, controlled: it wears the halo and the tooltip.
- **defaultSelected** (`string`): The loan selected at first; by default the weakest one under the target.
- **onSelect** (`(position: RiskPosition) => void`): Called when a loan is clicked, tapped, or chosen with Enter or Space.
- **defaultHovered** (`string`): A loan’s id whose hover card opens at first, the other loans faded, as if pointed at; held until the reader’s pointer or focus moves on. Turns the demo off.
- **entrance** (`boolean`, default `true`): The loans rise into place the first time it comes on screen; false draws them in place.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer hovers a few loans and selects one.
- **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 title, and the legend a list. The loans are a group of buttons with a single tab stop: the arrow keys move between them in the order of the track, Home and End jump to the ends, and Enter or Space selects (aria-pressed marks the selected one).
- Each loan’s button is named in full (“Halden, DLN loan, health 1.08, at risk”), and while it is hovered or focused the card is its description (role tooltip, aria-describedby). Escape hides the card.
- Selecting a loan announces it with its repay suggestion in a polite live region. The coins, the track and the visible tooltip are decorative (aria-hidden).
- Focus shows the same card as hover, with a visible ring around the coin. Under reduced motion nothing slides or fades and the demo never plays.

## Source

### risk-scale/risk-scale.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
} from 'react';
import { ASSETS, CoinGlyph, type RiskAsset } from './assets';
import { DemoPointer, runDemo } from './demo-pointer';
import { useAutoplay } from './use-autoplay';
import styles from './risk-scale.module.css';

/*
 * Risk scale: the health factor of every open loan on one track. The track is cut in four bands
 * (liquidation, at risk, watch, safe); each loan is a dot on it, its coin on a stem above and its
 * value and venue below. One loan is selected, by default the weakest under the target: a halo
 * marks it and a dark tooltip says how much to repay to lift it to the target.
 *
 * - The scale is logarithmic, from 0.9 to 5 by default: a step from 1.0 to 1.2 matters as much as
 *   one from 3 to 3.6, so the crowded low end gets the room and a 4.29 still fits.
 * - Hovering or focusing a loan brings up a card with its debt, collateral and the price its
 *   collateral would have to fall to for it to be liquidated (health 1.0). The other loans fade,
 *   and the card slides from loan to loan as the pointer moves along.
 * - The labels under the dots are measured once laid out: one that would overlap its neighbour
 *   drops to a second row (or a third), and those at the ends are kept inside the card.
 * - The loans are buttons in one tab stop: the arrow keys move between them in the order of the
 *   track, Enter or a click selects one (onSelect), Escape hides the card.
 * - The first time it comes on screen, the loans rise into place one after another, left to right,
 *   the tooltip with the first (at once under reduced motion).
 * - While on screen and untouched, a demo pointer hovers a few loans and selects one, then the
 *   weakest again. Any real input hands it to the reader; it never plays under reduced motion.
 * - 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 { RiskAsset };

export type RiskPosition = {
  id: string;
  /** Its health factor: collateral (weighted for liquidation) over debt. Under 1.0, liquidatable. */
  value: number;
  /** Where the loan is held. */
  venue: string;
  /** The coin borrowed: its glyph sits above the dot. */
  asset: RiskAsset;
  /** The network it lives on, under its name in the card. */
  network?: string;
  /** What is still owed, in dollars: it prices the repay suggestion. */
  debt?: number;
  /** What backs it: an amount of a coin and its worth in dollars. */
  collateral?: { amount: number; asset: RiskAsset; value: number };
};

export type RiskThresholds = {
  /** Under it, a loan can be liquidated. */
  liquidation: number;
  /** Under it (and above liquidation), a loan is at risk. */
  risk: number;
  /** Under it, a loan is watched; from it up, it is safe. */
  watch: number;
};

const DEFAULT_THRESHOLDS: RiskThresholds = { liquidation: 1, risk: 1.2, watch: 1.5 };

export const SAMPLE_POSITIONS: RiskPosition[] = [
  {
    id: 'halden-dln',
    value: 1.08,
    venue: 'Halden',
    asset: 'dln',
    network: 'Northway',
    debt: 13700,
    collateral: { amount: 128.4, asset: 'orn', value: 19600 },
  },
  {
    id: 'corvo-tsl',
    value: 1.34,
    venue: 'Corvo',
    asset: 'tsl',
    network: 'Basin',
    debt: 2300,
    collateral: { amount: 9.5, asset: 'orn', value: 4100 },
  },
  {
    id: 'brisa-dln-a',
    value: 1.61,
    venue: 'Brisa',
    asset: 'dln',
    network: 'Northway',
    debt: 21000,
    collateral: { amount: 12.6, asset: 'vlr', value: 43500 },
  },
  {
    id: 'brisa-dln-b',
    value: 1.86,
    venue: 'Brisa',
    asset: 'dln',
    network: 'Northway',
    debt: 37000,
    collateral: { amount: 24, asset: 'vlr', value: 82900 },
  },
  {
    id: 'mardel-tsl',
    value: 3.12,
    venue: 'Mardel',
    asset: 'tsl',
    network: 'Northway',
    debt: 1300,
    collateral: { amount: 0.42, asset: 'vlr', value: 5100 },
  },
  {
    id: 'quill-vlr',
    value: 4.29,
    venue: 'Quill',
    asset: 'vlr',
    network: 'Lumen',
    debt: 10500,
    collateral: { amount: 3120, asset: 'orn', value: 56300 },
  },
];

type Band = 'liquidation' | 'risk' | 'watch' | 'safe';

const BAND_NAMES: Record<Band, string> = {
  liquidation: 'Liquidation',
  risk: 'At risk',
  watch: 'Watch',
  safe: 'Safe',
};

/* The plot's geometry, in px from its top (risk-scale.module.css draws the same). */
/** The middle of the coins, and of the track. */
const ICON_MID = 48;
/** How far the hover card's bottom stands above the middle of the coins. */
const CARD_ABOVE = 23;
/** The least room kept between the card and the labels, or the edge of the screen. */
const CARD_GAP = 8;
/** The top of the labels, and the step to the next row of them. */
const LABEL_TOP = 88.5;
const LABEL_HEIGHT = 36;
const ROW = 38;
/** Labels closer than this in a row go to the next one. */
const LABEL_GAP = 10;
/** How far past the track's ends a label or a card may reach (into the card's padding). */
const OVERHANG = 14;
/** Where the tooltip's arrow sits from its left end, and the most rows of labels. */
const TIP_ARROW = 15;
const MAX_ROWS = 3;

const money = (amount: number, digits = 0) =>
  '$' +
  amount.toLocaleString('en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits });
const compact = (amount: number) =>
  amount >= 1000 ? `$${(amount / 1000).toFixed(1)}k` : money(amount);
const fixed = (value: number) => value.toFixed(2);

/**
 * The selected loan's suggestion: what to repay to lift it to the target, rounded up to $10.
 * Health is collateral over debt, so the debt that reaches the target is debt × value / target.
 */
function repayText(position: RiskPosition, target: number) {
  if (position.value >= target) return `${position.venue} is healthy, above ${fixed(target)}`;
  if (!position.debt) return `Lift ${position.venue} to ${fixed(target)}`;
  const repay = Math.ceil((position.debt * (1 - position.value / target)) / 10) * 10;
  return `Repay ${money(repay)} to lift ${position.venue} to ${fixed(target)}`;
}

export function RiskScale({
  positions = SAMPLE_POSITIONS,
  thresholds = DEFAULT_THRESHOLDS,
  target = 1.5,
  range = [0.9, 5],
  title = 'Liquidation risk',
  subtitle = 'Health factor of each open position',
  selected: selectedProp,
  defaultSelected,
  defaultHovered,
  onSelect,
  autoplay = true,
  entrance = true,
  colorScheme,
  className = '',
}: {
  /** The open loans, in any order. */
  positions?: RiskPosition[];
  /** Where the bands meet. */
  thresholds?: RiskThresholds;
  /** The health the repay suggestion lifts a loan to. */
  target?: number;
  /** The ends of the track; values beyond sit at its ends. */
  range?: [number, number];
  title?: string;
  subtitle?: string;
  /** The selected loan's id, controlled. */
  selected?: string;
  /** The loan selected at first; by default the weakest under the target. */
  defaultSelected?: string;
  /**
   * A loan whose hover card opens at first, the others faded, as if pointed at; held until the
   * reader's pointer or focus moves on. Giving it turns the demo off.
   */
  defaultHovered?: string;
  /** Called when a loan is clicked or chosen with Enter. */
  onSelect?: (position: RiskPosition) => void;
  /** While on screen and untouched, a demo pointer hovers a few loans and selects one. */
  autoplay?: boolean;
  /** The loans rise into place the first time it comes on screen; false draws them in place. */
  entrance?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const id = useId();
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(5000);
  // The entrance: the loans wait unseen until the component first comes on screen.
  const [entered, setEntered] = useState(!entrance);
  useEffect(() => {
    const element = root.current;
    if (!element || !entrance) return;
    if (
      typeof IntersectionObserver === 'undefined' ||
      window.matchMedia('(prefers-reduced-motion: reduce)').matches
    ) {
      setEntered(true);
      return;
    }
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (!entry.isIntersecting) return;
        setEntered(true);
        observer.disconnect();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    return () => observer.disconnect();
  }, [root, entrance]);
  const plot = useRef<HTMLDivElement>(null);
  const tip = useRef<HTMLDivElement>(null);
  const card = useRef<HTMLDivElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const buttons = useRef(new Map<string, HTMLButtonElement>());
  const labels = useRef(new Map<string, HTMLSpanElement>());

  // In the order of the track.
  const sorted = useMemo(() => [...positions].sort((a, b) => a.value - b.value), [positions]);
  const [low, high] = range;
  const at = (value: number) => {
    const clamped = Math.min(high, Math.max(low, value));
    return (Math.log(clamped) - Math.log(low)) / (Math.log(high) - Math.log(low));
  };
  const bandOf = (value: number): Band =>
    value < thresholds.liquidation
      ? 'liquidation'
      : value < thresholds.risk
        ? 'risk'
        : value < thresholds.watch
          ? 'watch'
          : 'safe';

  const weakest = sorted.find((position) => position.value < target)?.id ?? sorted[0]?.id;
  const [ownSelected, setOwnSelected] = useState(defaultSelected);
  const [demoSelected, setDemoSelected] = useState<string | null>(null);
  const [hovered, setHovered] = useState<string | null>(defaultHovered ?? null);
  const [demoHovered, setDemoHovered] = useState<string | null>(null);
  const [message, setMessage] = useState('');
  const selectedId = demoSelected ?? selectedProp ?? ownSelected ?? weakest;
  const selected = sorted.find((position) => position.id === selectedId) ?? sorted[0];
  const hoverId = demoHovered ?? hovered;
  const hover = sorted.find((position) => position.id === hoverId) ?? null;
  // The card keeps the last loan it showed while it fades out.
  const [shownId, setShownId] = useState<string | null>(null);
  if (hoverId && hoverId !== shownId) setShownId(hoverId);
  const shown = sorted.find((position) => position.id === shownId) ?? null;
  // The one key stop of the group: the focused loan, else the selected one.
  const [stop, setStop] = useState<string | null>(null);
  const tabStop = sorted.some((position) => position.id === stop) ? stop : selected?.id;

  // The labels' rows and nudges, the plot's width, and where the tooltip and the card go.
  const [width, setWidth] = useState(0);
  const [layout, setLayout] = useState<Record<string, { row: number; dx: number }>>({});
  const [tipWidth, setTipWidth] = useState(0);
  const [cardBox, setCardBox] = useState({ width: 0, height: 0, below: false });

  useLayoutEffect(() => {
    const element = plot.current;
    if (!element) return;
    const measure = () => setWidth(element.clientWidth);
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(element);
    return () => observer.disconnect();
  }, []);

  // Each label is centred under its dot, unless that runs it past the ends or into its neighbour:
  // left to right, each takes the first row where it clears the last label placed there.
  useLayoutEffect(() => {
    if (!width) return;
    const ends: number[] = [];
    const next: Record<string, { row: number; dx: number }> = {};
    for (const position of sorted) {
      const label = labels.current.get(position.id);
      const w = label?.offsetWidth ?? 0;
      const x = at(position.value) * width;
      const left = Math.min(width + OVERHANG - w, Math.max(-OVERHANG, x - w / 2));
      let row = ends.findIndex((end) => left >= end + LABEL_GAP);
      if (row < 0) row = ends.length < MAX_ROWS ? ends.length : MAX_ROWS - 1;
      ends[row] = left + w;
      next[position.id] = { row, dx: left - (x - w / 2) };
    }
    setLayout(next);
    // `at` reads the range: low and high stand for it below.
  }, [sorted, width, low, high]);

  const tipText = selected ? repayText(selected, target) : '';
  useLayoutEffect(() => {
    setTipWidth(tip.current?.offsetWidth ?? 0);
  }, [tipText, width]);

  const rows = Math.max(1, ...Object.values(layout).map((place) => place.row + 1));
  const plotHeight = LABEL_TOP + LABEL_HEIGHT + (rows - 1) * ROW;

  // The card is an overlay: it takes no room in the layout. It opens above the coins when the
  // screen, and every ancestor that clips, leaves it the room there, else under the labels (or
  // where there is more room). A card held open from the start (defaultHovered) is the one
  // exception: the component reserves the room above for it, so a frame that clips (a docs cell)
  // still shows all of it.
  const pinned = defaultHovered !== undefined;
  const [reserve, setReserve] = useState(0);
  useLayoutEffect(() => {
    const box = card.current;
    const area = plot.current;
    const stage = root.current;
    if (!hoverId || !box || !area || !stage) return;
    const height = box.offsetHeight;
    const need = pinned
      ? Math.max(0, Math.ceil(height + CARD_GAP - (area.offsetTop + ICON_MID - CARD_ABOVE)))
      : 0;
    setReserve((now) => Math.max(now, need));
    let below = false;
    if (!pinned) {
      const frame = area.getBoundingClientRect();
      const scale = frame.height / area.offsetHeight || 1;
      let top = 0;
      let bottom = window.innerHeight;
      for (let el = stage.parentElement; el && el !== document.body; el = el.parentElement) {
        if (getComputedStyle(el).overflowY === 'visible') continue;
        const rect = el.getBoundingClientRect();
        top = Math.max(top, rect.top);
        bottom = Math.min(bottom, rect.bottom);
      }
      const above = frame.top + (ICON_MID - CARD_ABOVE - height - CARD_GAP) * scale - top;
      const under = bottom - (frame.top + (plotHeight + CARD_GAP + height + CARD_GAP) * scale);
      below = above < 0 && under > above;
    }
    setCardBox({ width: box.offsetWidth, height, below });
  }, [hoverId, width, root, pinned, plotHeight]);

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

  const choose = (position: RiskPosition) => {
    if (selectedProp === undefined) setOwnSelected(position.id);
    onSelect?.(position);
    announce(`${position.venue} selected. ${repayText(position, target)}.`);
  };

  const onKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>, index: number) => {
    let next = -1;
    if (event.key === 'ArrowRight' || event.key === 'ArrowDown') next = index + 1;
    else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') next = index - 1;
    else if (event.key === 'Home') next = 0;
    else if (event.key === 'End') next = sorted.length - 1;
    else if (event.key === 'Escape') {
      if (hovered) {
        event.preventDefault();
        setHovered(null);
      }
      return;
    }
    if (next < 0) return;
    event.preventDefault();
    const target = sorted[Math.min(sorted.length - 1, Math.max(0, next))];
    buttons.current.get(target.id)?.focus();
  };

  // The demo: hover the weakest loans and a safe one, select the second, then the weakest again.
  const demo = autoplay && defaultHovered === undefined && playing;
  const demoHover = useEffectEvent((value: string | null) => setDemoHovered(value));
  const demoSelect = useEffectEvent((value: string | null) => setDemoSelected(value));
  const demoIds = useEffectEvent(() => sorted.map((position) => position.id));
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!demo || !stage || !arrow) return;
    const coinAt = (key: string) => {
      const button = buttons.current.get(key);
      const coin = button?.querySelector<HTMLElement>('[data-part="coin-plate"]');
      if (!coin) return { x: stage.offsetWidth / 2, y: stage.offsetHeight / 2 };
      const frame = stage.getBoundingClientRect();
      const scale = frame.width / stage.offsetWidth || 1;
      const rect = coin.getBoundingClientRect();
      return {
        x: (rect.left + rect.width * 0.55 - frame.left) / scale,
        y: (rect.top + rect.height * 0.6 - frame.top) / scale,
      };
    };
    const stopDemo = runDemo(stage, arrow, async ({ wait, glide, glyph }) => {
      for (;;) {
        const ids = demoIds();
        if (ids.length < 2) return;
        await wait(900);
        // Hover the two weakest, as the arrow passes over them.
        for (const key of [ids[0], ids[1]]) {
          await glide(coinAt(key), 620);
          demoHover(key);
          await wait(1300);
        }
        // A click on the second weakest: the halo and the tooltip move to it.
        glyph('hand');
        await wait(250);
        demoSelect(ids[1]);
        await wait(350);
        glyph('arrow');
        await wait(1200);
        // On to a safe loan, the card sliding along.
        const far = ids[Math.min(ids.length - 1, 3)];
        await glide(coinAt(far), 700);
        demoHover(far);
        await wait(1600);
        // Back to the weakest, and a click selects it again.
        const back = coinAt(ids[0]);
        await glide(back, 760);
        demoHover(ids[0]);
        await wait(500);
        glyph('hand');
        await wait(250);
        demoSelect(null);
        await wait(350);
        glyph('arrow');
        await wait(1100);
        demoHover(null);
        await glide({ x: back.x + 120, y: back.y + 150 }, 700);
        await wait(1400);
      }
    });
    return () => {
      stopDemo();
      setDemoHovered(null);
      setDemoSelected(null);
    };
  }, [demo, root]);

  // The tooltip: its arrow on the selected dot, its body kept inside the plot.
  const selectedX = selected ? at(selected.value) * width : 0;
  const tipLeft = Math.min(width + OVERHANG - tipWidth, Math.max(-OVERHANG, selectedX - TIP_ARROW));
  const hoverX = shown ? at(shown.value) * width : 0;
  const cardLeft = Math.min(
    width + OVERHANG - cardBox.width,
    Math.max(-OVERHANG, hoverX - cardBox.width / 2),
  );
  const cardTop = cardBox.below ? plotHeight + CARD_GAP : ICON_MID - CARD_ABOVE - cardBox.height;

  const bands: { band: Band; from: number; to: number }[] = [
    { band: 'liquidation', from: low, to: thresholds.liquidation },
    { band: 'risk', from: thresholds.liquidation, to: thresholds.risk },
    { band: 'watch', from: thresholds.risk, to: thresholds.watch },
    { band: 'safe', from: thresholds.watch, to: high },
  ];
  const legend: Record<Band, string> = {
    liquidation: `< ${thresholds.liquidation.toFixed(1)}`,
    risk: `${thresholds.liquidation.toFixed(1)}–${thresholds.risk.toFixed(1)}`,
    watch: `${thresholds.risk.toFixed(1)}–${thresholds.watch.toFixed(1)}`,
    safe: `${thresholds.watch.toFixed(1)}+`,
  };

  const shownBand = shown ? bandOf(shown.value) : 'safe';
  const shownCollateral = shown?.collateral;
  const cardId = `${id}-card`;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="stage"
      data-color-scheme={colorScheme}
      data-intro={entered ? 'in' : 'wait'}
      style={reserve ? { paddingTop: reserve } : undefined}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onPointerMove={(event) => {
        if (event.pointerType !== 'touch') take();
      }}
    >
      <section className={styles.card} data-part="card" aria-labelledby={`${id}-title`}>
        <header className={styles.header} data-part="header">
          <h3 className={styles.title} id={`${id}-title`} data-part="title">
            {title}
            <span className={styles.subtitle} data-part="subtitle">
              {subtitle}
            </span>
          </h3>
          <ul className={styles.legend} data-part="legend" aria-label="Bands">
            {bands.map(({ band }) => (
              <li key={band} className={styles.legendItem} data-band={band}>
                <span className={styles.swatch} aria-hidden="true" />
                {BAND_NAMES[band]} {legend[band]}
              </li>
            ))}
          </ul>
        </header>

        <div
          ref={plot}
          className={styles.plot}
          data-part="plot"
          data-hovering={hover ? true : undefined}
          style={{ height: plotHeight }}
        >
          <div className={styles.track} data-part="track" aria-hidden="true">
            {bands.map(({ band, from, to }) => {
              const a = at(from);
              const b = at(to);
              if (b <= a) return null;
              return (
                <span
                  key={band}
                  className={styles.segment}
                  data-band={band}
                  data-part="segment"
                  style={{ left: `${a * 100}%`, width: `${(b - a) * 100}%` }}
                />
              );
            })}
          </div>

          {selected && (
            <div
              ref={tip}
              className={styles.tip}
              data-part="tooltip"
              aria-hidden="true"
              style={{
                transform: `translateX(${tipLeft}px)`,
                ['--arrow' as string]: `${selectedX - tipLeft}px`,
                visibility: width ? undefined : 'hidden',
              }}
            >
              {tipText}
            </div>
          )}

          <div className={styles.markers} role="group" aria-label="Open positions">
            {sorted.map((position, index) => {
              const band = bandOf(position.value);
              const place = layout[position.id] ?? { row: 0, dx: 0 };
              const asset = ASSETS[position.asset];
              const isSelected = position.id === selected?.id;
              return (
                <button
                  key={position.id}
                  ref={(element) => {
                    if (element) buttons.current.set(position.id, element);
                    else buttons.current.delete(position.id);
                  }}
                  type="button"
                  className={styles.marker}
                  data-part="marker"
                  data-band={band}
                  data-selected={isSelected || undefined}
                  data-hovered={position.id === hoverId || undefined}
                  style={{
                    left: `${at(position.value) * 100}%`,
                    ['--i' as string]: index,
                  }}
                  tabIndex={position.id === tabStop ? 0 : -1}
                  aria-pressed={isSelected}
                  aria-label={`${position.venue}, ${asset.symbol} loan, health ${fixed(position.value)}, ${BAND_NAMES[band].toLowerCase()}`}
                  aria-describedby={position.id === hoverId ? cardId : undefined}
                  onPointerEnter={(event) => {
                    if (event.pointerType !== 'touch') setHovered(position.id);
                  }}
                  onPointerLeave={(event) => {
                    if (event.pointerType === 'touch') return;
                    setHovered((now) =>
                      now === position.id && !event.currentTarget.matches(':focus-visible')
                        ? null
                        : now,
                    );
                  }}
                  onFocus={() => {
                    setStop(position.id);
                    setHovered(position.id);
                  }}
                  onBlur={() => setHovered((now) => (now === position.id ? null : now))}
                  onClick={() => choose(position)}
                  onKeyDown={(event) => onKeyDown(event, index)}
                >
                  <span className={styles.halo} data-part="halo" />
                  <span className={styles.plate} data-part="coin-plate" />
                  <CoinGlyph asset={position.asset} className={styles.coin} />
                  <span className={styles.stem} data-part="stem" />
                  <span className={styles.dot} data-part="dot" />
                  <span
                    ref={(element) => {
                      if (element) labels.current.set(position.id, element);
                      else labels.current.delete(position.id);
                    }}
                    className={styles.label}
                    data-part="label"
                    style={{
                      transform: `translate(calc(-50% + ${place.dx}px), ${place.row * ROW}px)`,
                    }}
                  >
                    <span className={styles.value} data-part="value">
                      {fixed(position.value)}
                    </span>
                    <span className={styles.venue} data-part="venue">
                      {position.venue}
                    </span>
                  </span>
                </button>
              );
            })}
          </div>

          <div
            ref={card}
            id={cardId}
            role="tooltip"
            className={styles.hoverCard}
            data-part="hover-card"
            data-open={hover ? true : undefined}
            data-below={cardBox.below || undefined}
            style={{ transform: `translate(${cardLeft}px, ${cardTop}px)` }}
          >
            {shown && (
              <>
                <div className={styles.cardHead}>
                  <span className={styles.cardCoin} aria-hidden="true">
                    <CoinGlyph asset={shown.asset} className={styles.coinLarge} />
                  </span>
                  <span>
                    <span className={styles.cardTitle}>
                      {ASSETS[shown.asset].symbol} loan · {shown.venue}
                    </span>
                    {shown.network && <span className={styles.cardSub}>{shown.network}</span>}
                  </span>
                </div>
                <dl className={styles.facts}>
                  <div>
                    <dt>Health factor</dt>
                    <dd data-band={shownBand}>
                      {fixed(shown.value)} · {BAND_NAMES[shownBand]}
                    </dd>
                  </div>
                  {shown.debt !== undefined && (
                    <div>
                      <dt>Debt</dt>
                      <dd>{money(shown.debt)}</dd>
                    </div>
                  )}
                  {shownCollateral && (
                    <div>
                      <dt>Collateral</dt>
                      <dd>
                        {shownCollateral.amount.toFixed(2)} {ASSETS[shownCollateral.asset].symbol} ·{' '}
                        {compact(shownCollateral.value)}
                      </dd>
                    </div>
                  )}
                </dl>
                <p className={styles.cardNote}>
                  {shown.value < thresholds.liquidation ? (
                    <>Under {thresholds.liquidation.toFixed(1)}: open to liquidation now.</>
                  ) : shownCollateral ? (
                    <>
                      Liquidates if {ASSETS[shownCollateral.asset].symbol} falls to{' '}
                      <strong>
                        {money(
                          (shownCollateral.value / shownCollateral.amount) *
                            (thresholds.liquidation / shown.value),
                          2,
                        )}
                      </strong>{' '}
                      (−{((1 - thresholds.liquidation / shown.value) * 100).toFixed(1)}%). Click to
                      select.
                    </>
                  ) : (
                    <>{repayText(shown, target)}.</>
                  )}
                </p>
              </>
            )}
          </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">
        {message}
      </p>
    </div>
  );
}
```

### risk-scale/risk-scale.module.css

```css
/* Risk scale (risk-scale.tsx): a soft grey card with a title, a legend and the plot. The plot is a
   stack of layers on one coordinate system, in px from its top: the tooltip (1.5–28.5), the coins
   (middle at 48) on their stems, the track (middle at 77) with its dots, and the labels from 88.5,
   one row every 38 px.

   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). By day the coins are deep and the tooltip dark;
   at night the coins turn pastel and the tooltip light.

   Memory. Everything that moves (the tooltip, the hover card, the dots, the demo pointer) moves
   by transform or opacity; the labels are measured once per layout, never per frame. */

.root {
  --surface: #fafafa;
  --line: rgb(0 0 0 / 0.05);
  --ink: #161618;
  --ink-2: #161618;
  --muted: #939396;
  --legend-ink: #5c5c61;
  /* The bands of the track, and the dots on them. */
  --band-liquidation: #eb8c8a;
  --band-risk: #fbcecb;
  --band-watch: #fcdfbd;
  --band-safe: #c8efd4;
  --dot-liquidation: #d94a48;
  --dot-risk: #de7068;
  --dot-watch: #dea957;
  --dot-safe: #52b47e;
  /* The ring round each dot, the stems, and the soft shadow under the coins. */
  --ring: #fff;
  --stem: #d6d6da;
  --coin-shadow: rgb(0 0 0 / 0.14);
  /* The selected loan's tooltip. */
  --tip: #262626;
  --tip-ink: #fff;
  /* The hover card. */
  --pop: #fff;
  --pop-line: rgb(0 0 0 / 0.07);
  --pop-rule: #ececee;
  --pop-shadow: rgb(16 16 24 / 0.1);
  --plate: #f1f1f3;
  --note-ink: #77777c;
  /* The coins: their shape and their mark. */
  --coin-dln: #1d56b0;
  --coin-dln-ink: #fff;
  --coin-tsl: #11766a;
  --coin-tsl-ink: #fff;
  --coin-vlr: #463cc0;
  --coin-vlr-ink: #fff;
  --coin-disc: #e8e8eb;
  --coin-orn: #e0852c;
  --coin-orn-ink: #fff;
  /* The same on both schemes: the focus ring, and the demo's pointer. */
  --focus: #387bff;
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  position: relative;
  container-type: inline-size;
  display: grid;
  place-items: center;
  width: 100%;
  /* Only the room the card needs: the hover card is an overlay (see .hoverCard). */
  padding: 0 16px;
  box-sizing: border-box;
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  color: var(--ink);
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#fafafa, #161618);
    --line: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.05));
    --ink: light-dark(#161618, #f2f2f4);
    --ink-2: light-dark(#161618, #f2f2f4);
    --muted: light-dark(#939396, #747477);
    --legend-ink: light-dark(#5c5c61, #b1b1b4);
    --band-liquidation: light-dark(#eb8c8a, #c56a69);
    --band-risk: light-dark(#fbcecb, #4f2c30);
    --band-watch: light-dark(#fcdfbd, #4b3a1e);
    --band-safe: light-dark(#c8efd4, #243f30);
    --dot-liquidation: light-dark(#d94a48, #ff7d79);
    --dot-risk: light-dark(#de7068, #f68e88);
    --dot-watch: light-dark(#dea957, #f4c07f);
    --dot-safe: light-dark(#52b47e, #7cd6a4);
    --ring: light-dark(#fff, #0e0f11);
    --stem: light-dark(#d6d6da, #3a3a3f);
    --coin-shadow: light-dark(rgb(0 0 0 / 0.14), rgb(0 0 0 / 0.5));
    --tip: light-dark(#262626, #f3f3f5);
    --tip-ink: light-dark(#fff, #1d1d1f);
    --pop: light-dark(#fff, #1c1c1f);
    --pop-line: light-dark(rgb(0 0 0 / 0.07), rgb(255 255 255 / 0.08));
    --pop-rule: light-dark(#ececee, #2c2c30);
    --pop-shadow: light-dark(rgb(16 16 24 / 0.1), rgb(0 0 0 / 0.45));
    --plate: light-dark(#f1f1f3, #2a2a2f);
    --note-ink: light-dark(#77777c, #96969c);
    --coin-dln: light-dark(#1d56b0, #a3c6f2);
    --coin-dln-ink: light-dark(#fff, #173a6b);
    --coin-tsl: light-dark(#11766a, #66e0c8);
    --coin-tsl-ink: light-dark(#fff, #0d4a41);
    --coin-vlr: light-dark(#463cc0, #b9bdf6);
    --coin-vlr-ink: light-dark(#fff, #2e2a7a);
    --coin-disc: light-dark(#e8e8eb, #2c2c2f);
    --coin-orn: light-dark(#e0852c, #f6bf80);
    --coin-orn-ink: light-dark(#fff, #7a4210);
  }
}
/* 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;
}

.card {
  position: relative;
  width: min(100%, 852px);
  box-sizing: border-box;
  padding: 26px 26px 20px;
  border-radius: 20px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
}

/* The title, its subtitle beside it, and the legend on the right; they wrap on a narrow card. */
.header {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 8px 24px;
  margin-bottom: 16px;
}
.title {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 2px 10px;
  margin: 0;
  font-size: 13px;
  font-weight: 500;
  line-height: 18px;
  letter-spacing: -0.003em;
  color: var(--ink);
}
.subtitle {
  font-weight: 400;
  color: var(--muted);
}
.legend {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 19px;
  margin: 0;
  padding: 0;
  list-style: none;
  font-size: 13px;
  line-height: 18px;
  color: var(--legend-ink);
}
.legendItem {
  display: flex;
  align-items: center;
  gap: 7px;
  white-space: nowrap;
}
.swatch {
  width: 10px;
  height: 10px;
  border-radius: 2px;
  background: var(--band);
}

/* Each band's colours, for the swatches, the segments, the dots and the halo. */
.legendItem[data-band='liquidation'],
.segment[data-band='liquidation'],
.marker[data-band='liquidation'],
.facts dd[data-band='liquidation'] {
  --band: var(--band-liquidation);
  --dot: var(--dot-liquidation);
}
.legendItem[data-band='risk'],
.segment[data-band='risk'],
.marker[data-band='risk'],
.facts dd[data-band='risk'] {
  --band: var(--band-risk);
  --dot: var(--dot-risk);
}
.legendItem[data-band='watch'],
.segment[data-band='watch'],
.marker[data-band='watch'],
.facts dd[data-band='watch'] {
  --band: var(--band-watch);
  --dot: var(--dot-watch);
}
.legendItem[data-band='safe'],
.segment[data-band='safe'],
.marker[data-band='safe'],
.facts dd[data-band='safe'] {
  --band: var(--band-safe);
  --dot: var(--dot-safe);
}

.plot {
  position: relative;
}

/* The track: four pills cut 2.5 px apart, round at the two ends of the scale. */
.track {
  position: absolute;
  top: 71.5px;
  right: 0;
  left: 0;
  height: 11px;
}
.segment {
  position: absolute;
  top: 0;
  bottom: 0;
}
.segment::before {
  content: '';
  position: absolute;
  inset: 0 1.25px;
  border-radius: 3px;
  background: var(--band);
}
.segment:first-child::before {
  left: 0;
  border-radius: 99px 3px 3px 99px;
}
.segment:last-child::before {
  right: 0;
  border-radius: 3px 99px 99px 3px;
}

/* The selected loan's tooltip: a pill whose arrow points at the dot (--arrow from its left). */
.tip {
  position: absolute;
  top: 1.5px;
  left: 0;
  z-index: 2;
  height: 27px;
  padding: 0 11px;
  border-radius: 10px;
  background: var(--tip);
  color: var(--tip-ink);
  font-size: 13px;
  line-height: 27px;
  white-space: nowrap;
  transition: transform 0.36s cubic-bezier(0.3, 0.7, 0.2, 1);
}
.tip::after {
  content: '';
  position: absolute;
  top: 100%;
  left: var(--arrow, 18px);
  margin-left: -5.5px;
  border: 5.5px solid transparent;
  border-top-width: 6px;
  border-bottom: 0;
  border-top-color: var(--tip);
  transition: left 0.36s cubic-bezier(0.3, 0.7, 0.2, 1);
}

/* A loan: a zero-width button at its place on the scale; its parts hang from it. */
.markers {
  position: absolute;
  inset: 0;
}
.marker {
  position: absolute;
  top: 0;
  width: 0;
  height: 0;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  cursor: pointer;
  outline: none;
}
.marker:hover,
.marker:focus-visible,
.marker[data-hovered] {
  z-index: 1;
}
.plate,
.coin,
.stem,
.dot,
.halo,
.label {
  position: absolute;
  transition:
    opacity 0.2s ease,
    transform 0.24s cubic-bezier(0.3, 0.7, 0.2, 1);
}
.coin {
  top: 37.5px;
  left: -10.5px;
  width: 21px;
  height: 21px;
  overflow: visible;
  filter: drop-shadow(0 1px 1.5px var(--coin-shadow));
}
/* A round, clear plate a little wider than the coin: it carries the focus ring. */
.plate {
  top: 35px;
  left: -13px;
  width: 26px;
  height: 26px;
  border-radius: 50%;
}
.coin[data-coin='dln'],
.coinLarge[data-coin='dln'] {
  --coin: var(--coin-dln);
  --coin-ink: var(--coin-dln-ink);
}
.coin[data-coin='tsl'],
.coinLarge[data-coin='tsl'] {
  --coin: var(--coin-tsl);
  --coin-ink: var(--coin-tsl-ink);
}
.coin[data-coin='vlr'],
.coinLarge[data-coin='vlr'] {
  --coin: var(--coin-vlr);
  --coin-ink: var(--coin-vlr-ink);
}
.coin[data-coin='orn'],
.coinLarge[data-coin='orn'] {
  --coin: var(--coin-orn);
  --coin-ink: var(--coin-orn-ink);
}
.stem {
  top: 58.5px;
  left: -0.5px;
  width: 1px;
  height: 11.5px;
  background: var(--stem);
}
.dot {
  top: 70px;
  left: -7px;
  width: 14px;
  height: 14px;
  box-sizing: border-box;
  border: 1.5px solid var(--ring);
  border-radius: 50%;
  background: var(--dot);
}
.halo {
  top: 61px;
  left: -16px;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  background: color-mix(in srgb, var(--dot) 24%, transparent);
  opacity: 0;
  transform: scale(0.4);
}
.marker[data-selected] .halo {
  opacity: 1;
  transform: scale(1);
}
.marker[data-hovered] .dot {
  transform: scale(1.3);
}
.label {
  top: 88.5px;
  left: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  white-space: nowrap;
  transition: opacity 0.2s ease;
}
.value {
  font-size: 14px;
  line-height: 18px;
  color: var(--ink-2);
}
.venue {
  font-size: 14px;
  line-height: 18px;
  color: var(--muted);
}
/* While a loan is hovered, the others fade back. */
.plot[data-hovering] .marker:not([data-hovered]) :is(.plate, .coin, .stem, .dot, .label) {
  opacity: 0.32;
}
.plot[data-hovering] .marker:not([data-hovered]) .halo {
  opacity: 0.35;
}
.marker:focus-visible .plate {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}

/* The entrance: unseen until the component first comes on screen, then each loan rises 4 px and
   fades in, 40 ms after the one before it; the tooltip comes with the first. */
.root[data-intro='wait'] :is(.marker, .tip) {
  opacity: 0;
}
.root[data-intro='in'] .marker {
  animation: rise 0.3s cubic-bezier(0.2, 0.7, 0.2, 1) calc(var(--i, 0) * 40ms) backwards;
}
.root[data-intro='in'] .tip {
  animation: appear 0.3s ease backwards;
}
@keyframes rise {
  from {
    opacity: 0;
    translate: 0 4px;
  }
}
@keyframes appear {
  from {
    opacity: 0;
  }
}

/* The hover card: an overlay above the coins (under the labels when the screen or a clipping
   ancestor leaves no room there), sliding from loan to loan. It takes no room in the layout,
   except when held open from the start (defaultHovered), when the stage reserves the room above
   for it. Its box sets its place; the inner parts fade and grow in. */
.hoverCard {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 3;
  width: 280px;
  max-width: calc(100% + 28px);
  box-sizing: border-box;
  padding: 14px 14px 12px;
  border-radius: 14px;
  background: var(--pop);
  box-shadow:
    0 0 0 1px var(--pop-line),
    0 12px 32px -6px var(--pop-shadow),
    0 2px 6px var(--pop-shadow);
  font-size: 13px;
  line-height: 20px;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition:
    opacity 0.16s ease,
    visibility 0s linear 0.16s,
    transform 0.26s cubic-bezier(0.3, 0.7, 0.2, 1);
}
.hoverCard[data-open] {
  opacity: 1;
  visibility: visible;
  transition:
    opacity 0.16s ease,
    visibility 0s,
    transform 0.26s cubic-bezier(0.3, 0.7, 0.2, 1);
}
.cardHead {
  display: flex;
  align-items: flex-start;
  gap: 13px;
}
.cardCoin {
  display: grid;
  flex: none;
  place-items: center;
  width: 30px;
  height: 30px;
  margin: -2px 0 0 -4px;
  border-radius: 50%;
  background: var(--plate);
}
.coinLarge {
  width: 20px;
  height: 20px;
  overflow: visible;
}
.cardTitle {
  display: block;
  font-size: 14px;
  font-weight: 500;
  color: var(--ink);
}
.cardSub {
  display: block;
  font-size: 12px;
  line-height: 16px;
  color: var(--note-ink);
}
.facts {
  display: grid;
  gap: 2px;
  font-size: 13.5px;
  margin: 10px 0 0;
}
.facts div {
  display: flex;
  justify-content: space-between;
  gap: 16px;
}
.facts dt {
  color: var(--note-ink);
}
.facts dd {
  margin: 0;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}
.cardNote {
  margin: 10px 0 0;
  padding-top: 10px;
  border-top: 1px solid var(--pop-rule);
  font-size: 12.5px;
  line-height: 18px;
  color: var(--note-ink);
}
.cardNote strong {
  font-weight: 500;
  color: var(--ink);
}

/* The demo pointer, over everything; one of its glyphs shows at a time. */
.pointerLayer {
  position: absolute;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
}
.pointer {
  width: 22px;
  height: 22px;
}
.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;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* A narrow card: a little less padding. */
@container (max-width: 480px) {
  .card {
    padding: 20px 16px 16px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .root[data-intro='in'] :is(.marker, .tip) {
    animation: none;
  }
  .tip,
  .tip::after,
  .plate,
  .coin,
  .stem,
  .dot,
  .halo,
  .label,
  .hoverCard,
  .hoverCard[data-open] {
    transition: none;
  }
}
```

### risk-scale/assets.tsx

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

import type { ReactNode } from 'react';

/*
 * The coins a position can be in: invented names, each with a small drawn glyph. The glyph's two
 * colours are tokens of the stylesheet (risk-scale.module.css), a deep coin by day and a pastel
 * one at night, so the drawings are never restyled from here.
 */

export type RiskAsset = 'dln' | 'tsl' | 'vlr' | 'orn';

export const ASSETS: Record<RiskAsset, { symbol: string; name: string }> = {
  dln: { symbol: 'DLN', name: 'Dollen' },
  tsl: { symbol: 'TSL', name: 'Tessel' },
  vlr: { symbol: 'VLR', name: 'Velar' },
  orn: { symbol: 'ORN', name: 'Orrin' },
};

/** Each glyph in a 20 by 20 box: the coin's shape in --coin, its mark in --coin-ink. */
const GLYPHS: Record<RiskAsset, ReactNode> = {
  // A round dollar coin: a disc, a fine inner ring and an S with its bar.
  dln: (
    <>
      <circle cx="10" cy="10" r="10" fill="var(--coin)" />
      <circle cx="10" cy="10" r="6.9" fill="none" stroke="var(--coin-ink)" strokeWidth="1.1" />
      <path
        d="M12.2 8.1c-.2-.8-1-1.3-2.2-1.3-1.3 0-2.1.6-2.1 1.5 0 2 4.5 1 4.5 3.3 0 1-.9 1.6-2.4 1.6-1.3 0-2.2-.5-2.4-1.4M10 5.4v1.4M10 13.2v1.4"
        fill="none"
        stroke="var(--coin-ink)"
        strokeWidth="1.25"
        strokeLinecap="round"
      />
    </>
  ),
  // A cut stone, flat on top: a dollar on a ledger, two bars over a stem.
  tsl: (
    <>
      <path d="M5 2.5h10l4.2 5.2L10 18.2.8 7.7z" fill="var(--coin)" strokeLinejoin="round" />
      <path
        d="M6.2 6.4h7.6M7.4 8.7h5.2M10 6.6v6"
        fill="none"
        stroke="var(--coin-ink)"
        strokeWidth="1.4"
        strokeLinecap="round"
      />
    </>
  ),
  // A tall rhombus on a pale disc, with a lighter facet down its right side.
  vlr: (
    <>
      <circle cx="10" cy="10" r="10" fill="var(--coin-disc)" />
      <path d="M10 .4l6.3 9.6-6.3 9.6-6.3-9.6z" fill="var(--coin)" />
      <path d="M10 .4l6.3 9.6-6.3 3.4z" fill="var(--coin-ink)" opacity="0.32" />
      <path d="M10 13.4l6.3-3.4-6.3 9.6z" fill="var(--coin-ink)" opacity="0.18" />
    </>
  ),
  // An orange disc with a ring that opens at the top right.
  orn: (
    <>
      <circle cx="10" cy="10" r="10" fill="var(--coin)" />
      <path
        d="M14.4 6.6A5.5 5.5 0 1 0 15.5 10"
        fill="none"
        stroke="var(--coin-ink)"
        strokeWidth="1.8"
        strokeLinecap="round"
      />
      <circle cx="10" cy="10" r="1.7" fill="var(--coin-ink)" />
    </>
  ),
};

/** A coin's glyph; decorative, as its name is always written out beside it. */
export function CoinGlyph({ asset, className }: { asset: RiskAsset; className?: string }) {
  return (
    <svg
      className={className}
      data-part="coin"
      data-coin={asset}
      viewBox="0 0 20 20"
      aria-hidden="true"
      focusable="false"
    >
      {GLYPHS[asset]}
    </svg>
  );
}
```

### risk-scale/demo-pointer.tsx

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

'use client';

import type { CSSProperties, Ref } from 'react';

/*
 * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or
 * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends
 * the step in progress at once: its promise rejects, which ends the script right there, and the
 * pointer fades out.
 *
 * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame.
 * The points of the flight are handed to whoever wants to move along with it (anything that
 * plays its own animation on the same clock). The arrow and the hand are HTML boxes
 * that swap by their visibility: a change of style inside an SVG would lay the SVG out again.
 */

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

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

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

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

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

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

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

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

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

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

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

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

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

/**
 * The pointer's box: it moves, on a layer of its own. Its size comes from its class, its colours
 * from the stage's tokens (risk-scale.module.css).
 */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 2,
  display: 'block',
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
  willChange: 'transform',
};

/** One glyph over the other, each filling the box. */
const GLYPH: CSSProperties = { position: 'absolute', inset: 0, display: 'block' };
const DRAWING: CSSProperties = {
  display: 'block',
  width: '100%',
  height: '100%',
  overflow: 'visible',
};
/** The arrow is dark on a white edge, the hand white on a dark one, as the system draws them. */
const ARROW: CSSProperties = { fill: 'var(--pointer)', stroke: 'var(--pointer-edge)' };
const HAND: CSSProperties = { fill: 'var(--pointer-edge)', stroke: 'var(--pointer)' };

/**
 * The pointer the demo moves around: an arrow, or a pointing hand over a loan. It is
 * decorative: what it does shows on the scale 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>
  );
}
```

### risk-scale/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 };
}
```