# Readiness dashboard

> A compliance officer’s inspection-readiness screen: a lollipop month chart, headline figures, a prompt, nested report discs, a score gauge and an upload funnel.

If an inspector came today, how ready would the firm be? Tabs pick the measure, the office, Weekly / Monthly / Yearly and the year pick the figures, and hovering a month lights its value while the big change on the left compares it with the one before. Under it, a prompt that draws /tokens as chips, reports filed as nested discs, the readiness score on a wide gauge, and the client upload funnel with its own tooltip. Every chart is coded, in light and dark.

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

Category: Dashboards.

## Anatomy

1. **Rail**: The product and its sections as icons with labels, settings and help at the foot. Under 700 px it narrows to icons, under 560 px it becomes a top bar with a menu button.
2. **Top bar**: A search over deals, parties and reports (a combobox with its results as a listbox), notifications and the signed-in officer.
3. **Title and controls**: The screen’s question, a verdict badge, then the office, Weekly / Monthly / Yearly, the year and Build inspection pack, which shows its own progress.
4. **Tabs**: The measure the chart shows; Gaps carries its count. Arrows, Home and End move between them.
5. **Month chart**: A lollipop per period with the change it is about on its left. Hovering a column lights it with its value; clicking or the arrows pick it.
6. **Headline figures**: The latest month’s cleared share, the overdue items and the training done, each with a badge, over a dot per agent with the ones due in grey.
7. **Prompt**: Folds open to a field that draws /tokens as chips over the browser’s own caret, and answers under it.
8. **Evidence cards**: Reports filed as nested discs, the readiness score on a gauge against the pass mark of 60, and the client upload funnel.

## When to use

- One question a whole screen answers, here “if an inspector came today, how ready are we?”, with the verdict at the top and the evidence under it.
- A month-by-month measure that people read as a change: the big figure compares the lit month with the one before, in points for a share and in units for a count.
- A few related measures behind tabs, sharing one chart and one set of filters (office, period, year), so switching keeps the reader’s place.
- A screen that mixes kinds of evidence: a trend, headline figures, a score against a pass mark, a breakdown by kind and a step-by-step funnel.
- Where the next question is open-ended: the prompt lets the reader ask the data in words, with `/September` pointing at a month.

## When not to use

- A single figure or a single chart. Use a stat card or the chart on its own, not a whole screen.
- Data people need to sort, filter by many fields or export row by row. Use a table, such as the records table.
- Live operations that change by the second: the figures here are period totals that count in once.
- More than six measures, or measures with nothing in common: tabs over one chart only work when every tab reads on the same axis of time.
- A screen the reader edits: nothing here is input but the filters, the search and the prompt. Use a form or a workspace.

## Installation

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

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

Or copy all the source files below into one folder of your project. Either way it needs React 19 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

## Usage

```tsx
import { ReadinessDashboard } from './readiness-dashboard';

export function CompliancePage() {
  return (
    <ReadinessDashboard
      user={{ name: 'Mira', role: 'Compliance officer' }}
      defaultScope="Whole firm"
      onBuildPack={() => console.log('Building the pack')}
      autoplay={false}
    />
  );
}
```

## States

- **At rest**: Summary, by month, for the whole firm: October, the latest month, is picked and compared with September.
- **Month hovered**: July lit with its value over the dot; the big change now compares July with June.
- **Weekly figures**: The same measure over the last twelve weeks, the latest week picked.
- **Gaps tab**: A count where fewer is better, so a fall reads as good news.
- **Office select open**: A listbox under its button, the current office ticked.
- **Search results**: Deals, parties and reports matching the text, each with its kind.
- **Notifications**: Three things to act on, from the bell; Escape or a press elsewhere closes them.
- **Score breakdown**: What the 86 is made of, four parts out of 100, from the arrow on the score card.
- **Prompt answered**: The question’s answer under the field: the agents with late clearances in September.
- **Pack building**: Build inspection pack shows its progress in place; the spinner keeps turning while it is held.
- **Phone, menu open**: Under 560 px the rail is a top bar; its menu button opens the sections over the screen.

## How it works

- Every chart is HTML and CSS but the gauge, one small SVG. Values move by transforms only: a stem is a full-height line scaled from its foot, a dot, a bar or a tooltip a full-height box slid down by (1 − share) of its own height, a disc scaled from the point it stands on. A new series glides in column after column (a 28 ms stagger), and nothing is laid out again.
- The lollipop chart is a radio group: hovering a column lights it (its back, a white chip and a value pill over the dot), clicking or the arrow keys pick it, and the change on the left compares the lit column with the one before, in points for a share and in units for a count. Months not on record yet are outlined and skipped.
- The scale is fitted to each series: the lowest value stands 0.7 of the range up its column and the highest leaves half a range for its pill, so every tab reads like the summary.
- The report discs stand on one point and are sized by the square root of their counts, so their areas compare; each count sits in the band between its disc’s top and the next one’s.
- The gauge is a 378-unit arc whose centre lies under the card, so it runs out of both sides: grey up to the pass mark, blue up to the score, hatched beyond. The figures count up over 1.1 s the first time the dashboard is on screen, and the gauge with them.
- The prompt’s field has transparent text over a copy that draws its /tokens as chips (their padding is a box-shadow, so the copy keeps the field’s exact metrics), so the caret and the selection stay the browser’s.
- It lays itself out from its own width with container queries: the full screen from 1180 px; then the chart’s words over it and two cards a row; the figures card under the chart below 860 px; an icon rail below 700 px; and below 560 px a top bar with a menu button, the tabs scrolling sideways.
- While on screen and untouched, a demo pointer hovers three months, switches to Weekly and back, opens Training, types a question into the prompt and walks the funnel and the discs. Any real input hands it over; it comes back after 9 s of quiet.
- Every colour is a light-dark() token on the root: a near-black frame with cards a step lighter by night, a pale grey frame with white cards by day, the same periwinkle blue for the data and a pink family for the reports.

## API reference

- **defaultTab** (`'summary' | 'deals' | 'reports' | 'screening' | 'training' | 'gaps'`, default `'summary'`): The measure the chart shows at first.
- **defaultPeriod** (`'weekly' | 'monthly' | 'yearly'`, default `'monthly'`): Weekly, monthly or yearly figures at first.
- **defaultScope** (`'Whole firm' | 'Harbour office' | 'Old Town office'`, default `'Whole firm'`): The office the figures are for, at first.
- **user** (`{ name: string; role: string }`, default `{ name: 'Mira', role: 'Compliance officer' }`): Who is signed in, top right.
- **onBuildPack** (`() => void`): Called when Build inspection pack is pressed; the button then shows its own progress.
- **onTabChange** (`(tab: TabId) => void`): Called when the reader picks a tab, with its id.
- **onPeriodChange** (`(period: Period) => void`): Called when the reader picks Weekly, Monthly or Yearly.
- **onScopeChange** (`(scope: Scope) => void`): Called when the reader picks an office.
- **defaultState** (`ReadinessDashboardState`): Opens in this moment, held still (no count-in, no rise): hover and picked (a month column lit or picked, by its index), year, open ('office' | 'notifications' | 'breakdown' | 'search' | 'menu', a popover or, on a phone, the menu), query (the search’s text), prompt (the prompt’s text), ask ('closed' | 'thinking' | 'answered'), pack ('building' | 'ready', held until pressed again), ring (the report disc lit: 0 all, 1 TTR, 2 STR, 3 match reports), funnel (a funnel step lit, 0–3), reportsPeriod and funnelMonth. Turns the demo off; a popover it opens ignores presses outside the dashboard until the dashboard is used.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer shows the main interactions. Any real input stops it.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark colours, or the reader’s; without it, the page’s color-scheme.
- **className** (`string`): Extra class for the root.

## Accessibility

- Real landmarks and names: the rail is a navigation with aria-current on its section, the page a main with a heading, each card a section named by its title. On a phone the rail’s menu button is a disclosure.
- The measures are tabs (arrows, Home and End) over a tab panel. The chart’s months and the funnel’s steps are radio groups with one tab stop, each column named with its value (“October: 95.3%”); months not on record yet are skipped.
- The office, year, reports and funnel selects are listboxes opened from a button: arrows, Home, End, Enter and Escape, with the focus returned to the button. The search is a combobox with its results as a listbox.
- Notifications, the score’s breakdown and the menu close on Escape or a press elsewhere. Period changes, a search pick, the pack’s progress and the prompt’s answer are announced in a polite live region.
- The discs, the gauge and the dot grid are drawn for the eye; their figures are in the legend, the score and the grid’s label. Under reduced motion nothing counts or rises and the demo never plays.

## Source

### readiness-dashboard/readiness-dashboard.tsx

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

'use client';

/*
 * Readiness dashboard: a compliance officer's screen for "if an inspector came today". An icon
 * rail, a search, the period controls and tabs over a month-by-month lollipop chart, a card of
 * headline figures, a prompt to ask the data a question, and three cards under it: reports filed
 * (nested discs), the readiness score (a gauge) and the client upload funnel.
 *
 * - Every chart is HTML and CSS but the gauge, one small SVG. Values move by transforms only: a
 *   stem is a full-height line scaled from its foot, a dot or a bar a full-height box slid down,
 *   a disc scaled from the point it stands on. A new series glides in, column after column.
 * - The tabs pick what the chart shows; the office, Weekly / Monthly / Yearly and the year pick
 *   which figures. Hovering a column lights it with its value; clicking or the arrow keys pick it,
 *   and the big change on the left compares it with the one before.
 * - The figures count up the first time the dashboard comes on screen (at once under reduced
 *   motion), and the stems, discs and bars rise from the floor.
 * - The prompt shows /tokens as chips: the field's own text is transparent over a copy of it that
 *   draws them, so the caret and the selection stay the browser's.
 * - While on screen and untouched, a demo pointer hovers the months, switches the period and a
 *   tab, types a question into the prompt and walks the funnel and the discs. Any real input
 *   hands it over to the reader; it starts again after a while.
 * - It lays itself out from its own width (container queries): the full screen from 1180 px, a
 *   narrower rail and stacked cards below, a top bar with a menu button under 560 px.
 * - 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.
 */

import {
  useEffect,
  useEffectEvent,
  useId,
  useRef,
  useState,
  type FormEvent,
  type ReactNode,
} from 'react';
import { LollipopChart, ReportRings, ScoreGauge, UploadFunnel } from './charts';
import {
  AGENTS,
  ANSWER,
  FUNNEL,
  FUNNEL_MONTHS,
  METRICS,
  NOTICES,
  PRODUCT,
  REPORTS,
  REPORT_PERIODS,
  SCOPES,
  SCORE,
  SEARCHABLE,
  TRAINING_DUE,
  YEARS,
  formatChange,
  formatValue,
  seriesFor,
  type FunnelMonth,
  type Period,
  type ReportsPeriod,
  type Scope,
  type TabId,
  type Year,
} from './data';
import { DemoPointer, runDemo, type Point as ScreenPoint } from './demo-pointer';
import { icons, type IconName } from './icons';
import { Picker } from './picker';
import { useAutoplay } from './use-autoplay';
import styles from './readiness-dashboard.module.css';

const NAV: { id: string; label: string; icon: IconName }[] = [
  { id: 'queue', label: 'Queue', icon: 'queue' },
  { id: 'deals', label: 'Deals', icon: 'deals' },
  { id: 'reports', label: 'Reports', icon: 'reports' },
  { id: 'cases', label: 'Cases', icon: 'cases' },
  { id: 'lists', label: 'Lists', icon: 'lists' },
  { id: 'readiness', label: 'Readiness', icon: 'readiness' },
];
const NAV_END: { id: string; label: string; icon: IconName }[] = [
  { id: 'settings', label: 'Settings', icon: 'settings' },
  { id: 'help', label: 'Help', icon: 'help' },
];
const PERIODS: { id: Period; label: string; unit: string }[] = [
  { id: 'weekly', label: 'Weekly', unit: 'week' },
  { id: 'monthly', label: 'Monthly', unit: 'month' },
  { id: 'yearly', label: 'Yearly', unit: 'year' },
];
/** What the score is made of, out of 100. */
const BREAKDOWN = [
  { name: 'Deals cleared', score: 34, of: 40 },
  { name: 'Reports on time', score: 24, of: 25 },
  { name: 'Screening', score: 18, of: 20 },
  { name: 'Training', score: 10, of: 15 },
];
const PROMPT = 'Show me which agents had late clearances in /September';

/** 0 to 1 over `ms` once `go` turns true, eased out; 1 at once when motion is reduced. */
function useCountIn(go: boolean, ms = 1100) {
  const [progress, setProgress] = useState(0);
  useEffect(() => {
    if (!go) return;
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      setProgress(1);
      return;
    }
    let frame = 0;
    const start = performance.now();
    const tick = (now: number) => {
      const t = Math.min(1, (now - start) / ms);
      setProgress(1 - (1 - t) ** 3);
      if (t < 1) frame = requestAnimationFrame(tick);
    };
    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
  }, [go, ms]);
  return progress;
}

/** The prompt's text with its /tokens drawn as chips. */
function withTokens(text: string) {
  return text.split(/(\/[A-Za-z]+)/).map((part, index) =>
    part.startsWith('/') ? (
      <mark key={index} className={styles.token} data-part="token">
        {part}
      </mark>
    ) : (
      <span key={index}>{part}</span>
    ),
  );
}

/**
 * A moment of the dashboard to open in, held still: no count-in, no rise, no demo. A popover
 * opened by it ignores presses outside the dashboard until the dashboard is used.
 */
export type ReadinessDashboardState = {
  /** The month column lit as if hovered (its index in the series). */
  hover?: number;
  /** The month column picked (its index in the series). */
  picked?: number;
  year?: Year;
  /** A popover or menu open: the office select, notifications, the score's breakdown, the
   * search's results or (on a phone) the menu. */
  open?: 'office' | 'notifications' | 'breakdown' | 'search' | 'menu';
  /** The search's text. */
  query?: string;
  /** The prompt's text. */
  prompt?: string;
  /** The prompt folded away, working out an answer, or answered. */
  ask?: 'closed' | 'thinking' | 'answered';
  /** The inspection pack building or ready, held until it is pressed again. */
  pack?: 'building' | 'ready';
  /** The report disc lit (0 all, 1 TTR, 2 STR, 3 match reports). */
  ring?: number;
  /** The funnel step lit as if hovered (0–3). */
  funnel?: number;
  reportsPeriod?: ReportsPeriod;
  funnelMonth?: FunnelMonth;
};

function Badge({ tone, children }: { tone: 'good' | 'bad' | 'warn'; children: ReactNode }) {
  return (
    <span className={styles.badge} data-tone={tone}>
      {children}
    </span>
  );
}

export function ReadinessDashboard({
  autoplay = true,
  defaultTab = 'summary',
  defaultPeriod = 'monthly',
  defaultScope = 'Whole firm',
  user = { name: 'Mira', role: 'Compliance officer' },
  onBuildPack,
  onTabChange,
  onPeriodChange,
  onScopeChange,
  defaultState,
  colorScheme,
  className = '',
}: {
  /** While on screen and untouched, a demo pointer shows the main interactions. */
  autoplay?: boolean;
  /** The tab open at first. */
  defaultTab?: TabId;
  /** Weekly, monthly or yearly figures at first. */
  defaultPeriod?: Period;
  /** The office the figures are for, at first. */
  defaultScope?: Scope;
  /** Who is signed in, top right. */
  user?: { name: string; role: string };
  /** Called when "Build inspection pack" is pressed (it then shows its own progress). */
  onBuildPack?: () => void;
  /** Called when the reader picks a tab. */
  onTabChange?: (tab: TabId) => void;
  /** Called when the reader picks Weekly, Monthly or Yearly. */
  onPeriodChange?: (period: Period) => void;
  /** Called when the reader picks an office. */
  onScopeChange?: (scope: Scope) => void;
  /** Opens in a given moment, held still (no count-in, no rise, no demo). */
  defaultState?: ReadinessDashboardState;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(9000);
  const pointer = useRef<HTMLSpanElement>(null);
  const id = useId();

  const held = defaultState !== undefined;
  const start = defaultState ?? {};
  /** A popover opened by defaultState ignores presses outside until the dashboard is used. */
  const holding = useRef(Boolean(start.open));

  const [nav, setNav] = useState('readiness');
  const [menuOpen, setMenuOpen] = useState(start.open === 'menu');
  const [tab, setTab] = useState<TabId>(defaultTab);
  const [period, setPeriod] = useState<Period>(defaultPeriod);
  const [scope, setScope] = useState<Scope>(defaultScope);
  const [year, setYear] = useState<Year>(start.year ?? 'Year 2026');
  const [picked, setPicked] = useState<number | null>(start.picked ?? null);
  const [hovered, setHovered] = useState<number | null>(start.hover ?? null);
  const [reportsPeriod, setReportsPeriod] = useState<ReportsPeriod>(
    start.reportsPeriod ?? 'This year',
  );
  const [ring, setRing] = useState<number | null>(start.ring ?? null);
  const [funnelMonth, setFunnelMonth] = useState<FunnelMonth>(start.funnelMonth ?? 'October');
  const [funnelPicked, setFunnelPicked] = useState(2);
  const [funnelHover, setFunnelHover] = useState<number | null>(start.funnel ?? null);
  const [agent, setAgent] = useState<number | null>(null);
  const [pack, setPack] = useState<'idle' | 'building' | 'ready'>(start.pack ?? 'idle');
  const [bellOpen, setBellOpen] = useState(start.open === 'notifications');
  const [breakdownOpen, setBreakdownOpen] = useState(start.open === 'breakdown');
  const [askOpen, setAskOpen] = useState(start.ask !== 'closed');
  const [prompt, setPrompt] = useState(start.prompt ?? PROMPT);
  const [answer, setAnswer] = useState<'thinking' | { month: string } | null>(
    start.ask === 'thinking'
      ? 'thinking'
      : start.ask === 'answered'
        ? { month: 'September' }
        : null,
  );
  const [query, setQuery] = useState(start.query ?? '');
  const [searchOpen, setSearchOpen] = useState(start.open === 'search');
  const [searchActive, setSearchActive] = useState(0);
  const [message, setMessage] = useState('');
  const [seen, setSeen] = useState(held);
  const mirror = useRef<HTMLDivElement>(null);
  const field = useRef<HTMLInputElement>(null);
  const packTimer = useRef(0);
  const answerTimer = useRef(0);

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

  // The figures count in, and the charts rise, the first time the dashboard is on screen.
  useEffect(() => {
    const element = root.current;
    if (!element || held) return;
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (!entry.isIntersecting) return;
        setSeen(true);
        observer.disconnect();
      },
      { rootMargin: '0px 0px -10% 0px' },
    );
    observer.observe(element);
    return () => observer.disconnect();
  }, [root, held]);
  const countIn = useCountIn(seen && !held);
  const count = held ? 1 : countIn;

  useEffect(
    () => () => {
      window.clearTimeout(packTimer.current);
      window.clearTimeout(answerTimer.current);
    },
    [],
  );

  // Popovers close on a press elsewhere or Escape.
  useEffect(() => {
    if (!bellOpen && !breakdownOpen && !menuOpen && !searchOpen) return;
    const element = root.current;
    const away = (event: PointerEvent) => {
      const target = event.target as Element;
      if (holding.current && !element?.contains(target)) return;
      holding.current = false;
      if (!target.closest?.('[data-popover]')) {
        setBellOpen(false);
        setBreakdownOpen(false);
        setMenuOpen(false);
        setSearchOpen(false);
      }
    };
    const escape = (event: KeyboardEvent) => {
      if (event.key !== 'Escape') return;
      setBellOpen(false);
      setBreakdownOpen(false);
      setMenuOpen(false);
      setSearchOpen(false);
    };
    document.addEventListener('pointerdown', away);
    element?.addEventListener('keydown', escape);
    return () => {
      document.removeEventListener('pointerdown', away);
      element?.removeEventListener('keydown', escape);
    };
  }, [bellOpen, breakdownOpen, menuOpen, searchOpen, root]);

  // The figures.
  const metric = METRICS.find((item) => item.id === tab) ?? METRICS[0];
  const points = seriesFor(metric, period, scope, year);
  const latest = points.reduce((last, point, index) => (point.value === null ? last : index), 0);
  const chosen =
    picked !== null && picked < points.length && points[picked].value !== null ? picked : latest;
  const shown = hovered !== null && points[hovered]?.value != null ? hovered : chosen;
  const current = points[shown];
  const before = shown > 0 ? points[shown - 1] : null;
  const change =
    before && before.value !== null && current.value !== null
      ? Math.round((current.value - before.value) * 10) / 10
      : null;
  const capital = (text: string) => text.charAt(0).toUpperCase() + text.slice(1);
  const headline =
    change === null
      ? formatValue(metric, current.value ?? 0)
      : formatChange(
          metric,
          metric.unit === '%' ? Math.round(change * count * 10) / 10 : Math.round(change * count),
        );
  const sentence =
    change === null || !before
      ? `${capital(current.long)}, the first on this chart`
      : change === 0
        ? `${capital(current.long)} held level with ${before.long}`
        : `${capital(current.long)} ${change > 0 ? metric.rose : metric.fell} than ${before.long}`;

  // The card of headline figures: the latest month of the summary and the deals.
  const summary = seriesFor(METRICS[0], 'monthly', scope, year);
  const deals = seriesFor(METRICS[1], 'monthly', scope, year);
  const last = summary.reduce((end, point, index) => (point.value === null ? end : index), 0);
  const cleared = summary[last].value ?? 0;
  const clearedChange = Math.round((cleared - (summary[last - 1]?.value ?? cleared)) * 10) / 10;
  const dealsCleared = deals[last].value ?? 0;
  const dealsTotal = Math.max(dealsCleared, Math.round(dealsCleared / (cleared / 100)));
  const monthName = summary[last].long;

  const reports = REPORTS[reportsPeriod];
  const score = SCORE[scope];
  const shownScore = Math.round(score.now * count);
  const gaugeScore = 60 + (score.now - 60) * count;
  const funnel = FUNNEL[funnelMonth];
  const funnelShown = funnelHover ?? funnelPicked;
  const secondRequest = Math.round((1 - funnel[3] / funnel[2]) * 100);

  const matches = SEARCHABLE.filter((item) =>
    `${item.kind} ${item.name}`.toLowerCase().includes(query.trim().toLowerCase()),
  ).slice(0, 6);

  // Actions, shared by the reader and the demo.
  const choosePeriod = (next: Period) => {
    onPeriodChange?.(next);
    setPeriod(next);
    setPicked(null);
    announce(`${PERIODS.find((item) => item.id === next)?.label} figures`);
  };
  const chooseTab = (next: TabId) => {
    setTab(next);
    setPicked(null);
  };
  const chooseScope = (next: Scope) => {
    onScopeChange?.(next);
    setScope(next);
  };
  const buildPack = () => {
    if (pack === 'building') return;
    onBuildPack?.();
    setPack('building');
    announce('Building the inspection pack');
    window.clearTimeout(packTimer.current);
    packTimer.current = window.setTimeout(() => {
      setPack('ready');
      announce('Inspection pack ready');
      packTimer.current = window.setTimeout(() => setPack('idle'), 2600);
    }, 1700);
  };
  const ask = (text: string) => {
    if (!text.trim()) return;
    const month = /\/([A-Za-z]+)/.exec(text)?.[1] ?? 'September';
    setAnswer('thinking');
    window.clearTimeout(answerTimer.current);
    answerTimer.current = window.setTimeout(() => {
      setAnswer({ month: capital(month.toLowerCase()) });
      announce('Answer ready');
    }, 900);
  };
  const syncMirror = () => {
    if (mirror.current && field.current) mirror.current.scrollLeft = field.current.scrollLeft;
  };

  // The demo.
  const demoAct = useEffectEvent((name: string, value?: unknown) => {
    switch (name) {
      case 'hover':
        setHovered(value as number | null);
        break;
      case 'period':
        setPeriod(value as Period);
        setPicked(null);
        break;
      case 'tab':
        chooseTab(value as TabId);
        break;
      case 'prompt':
        setPrompt(value as string);
        break;
      case 'ask':
        ask(PROMPT);
        break;
      case 'reset':
        setAnswer(null);
        setAskOpen(true);
        break;
      case 'funnel':
        setFunnelHover(value as number | null);
        break;
      case 'ring':
        setRing(value as number | null);
        break;
    }
  });
  const demo = autoplay && !held && playing;
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!demo || !stage || !arrow) return;
    const centre = (name: string, dx = 0.5, dy = 0.5): ScreenPoint | null => {
      const target = stage.querySelector<HTMLElement>(`[data-demo="${name}"]`);
      if (!target || !target.offsetParent) return null;
      const frame = stage.getBoundingClientRect();
      const scale = frame.width / stage.offsetWidth || 1;
      const rect = target.getBoundingClientRect();
      return {
        x: (rect.left + rect.width * dx - frame.left) / scale,
        y: (rect.top + rect.height * dy - frame.top) / scale,
      };
    };
    const stop = runDemo(stage, arrow, async ({ wait, glide, glyph }) => {
      const go = async (name: string, ms = 620, dx?: number, dy?: number) => {
        const to = centre(name, dx, dy);
        if (to) await glide(to, ms);
        return Boolean(to);
      };
      for (;;) {
        demoAct('reset');
        await wait(700);
        // Hover a few months of the chart.
        glyph('arrow');
        for (const month of [8, 5, 9]) {
          if (await go(`column-${month}`, 640, 0.5, 0.45)) demoAct('hover', month);
          await wait(950);
        }
        demoAct('hover', null);
        // Weekly figures, then back to monthly.
        glyph('hand');
        await go('period-weekly');
        await wait(200);
        demoAct('period', 'weekly');
        await wait(1700);
        await go('period-monthly', 420);
        await wait(200);
        demoAct('period', 'monthly');
        await wait(900);
        // The Training tab, a hover there, and back to the summary.
        await go('tab-training');
        await wait(200);
        demoAct('tab', 'training');
        await wait(1100);
        glyph('arrow');
        if (await go('column-7', 600, 0.5, 0.45)) demoAct('hover', 7);
        await wait(1000);
        demoAct('hover', null);
        glyph('hand');
        await go('tab-summary', 700);
        await wait(200);
        demoAct('tab', 'summary');
        await wait(800);
        // Ask a question in the prompt.
        if (await go('prompt', 700, 0.3, 0.5)) {
          await wait(250);
          demoAct('prompt', '');
          glyph('arrow');
          for (let length = 1; length <= PROMPT.length; length += 1) {
            demoAct('prompt', PROMPT.slice(0, length));
            await wait(PROMPT[length - 1] === ' ' ? 70 : 38);
          }
          await wait(450);
          demoAct('ask');
          await wait(2600);
        }
        // Walk the funnel, then the discs.
        glyph('arrow');
        for (const index of [1, 3]) {
          if (await go(`funnel-${index}`, 700, 0.5, 0.7)) demoAct('funnel', index);
          await wait(1100);
        }
        demoAct('funnel', null);
        for (const index of [2, 3]) {
          if (await go(`legend-${index}`, 700, 0.35)) demoAct('ring', index);
          await wait(1000);
        }
        demoAct('ring', null);
        await wait(1200);
      }
    });
    return () => {
      stop();
      demoAct('hover', null);
      demoAct('funnel', null);
      demoAct('ring', null);
    };
  }, [demo, root]);

  const navItem = (item: { id: string; label: string; icon: IconName }) => {
    const Icon = icons[item.icon];
    return (
      <li key={item.id}>
        <button
          type="button"
          className={styles.navItem}
          aria-current={nav === item.id ? 'page' : undefined}
          onClick={() => {
            setNav(item.id);
            setMenuOpen(false);
          }}
        >
          <Icon className={styles.navIcon} />
          <span className={styles.navLabel}>{item.label}</span>
        </button>
      </li>
    );
  };

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="dashboard"
      data-color-scheme={colorScheme}
      data-ready={seen || undefined}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onWheelCapture={take}
      onPointerMove={(event) => {
        if (event.pointerType !== 'touch') take();
      }}
    >
      <div className={styles.frame}>
        {/* The rail: the product, the sections, settings and help. A top bar on a phone. */}
        <nav className={styles.rail} aria-label="Sections" data-part="sidebar" data-popover>
          <div className={styles.brand}>
            <span className={styles.logo} aria-hidden="true">
              <icons.key className={styles.logoIcon} />
            </span>
            <span className={styles.brandName}>{PRODUCT.toLowerCase()}</span>
          </div>
          <button
            type="button"
            className={styles.menuButton}
            aria-expanded={menuOpen}
            aria-controls={`${id}-nav`}
            onClick={() => setMenuOpen((open) => !open)}
          >
            {menuOpen ? (
              <icons.close className={styles.navIcon} />
            ) : (
              <icons.menu className={styles.navIcon} />
            )}
            <span className={styles.hidden}>Menu</span>
          </button>
          <div id={`${id}-nav`} className={styles.navLists} data-open={menuOpen || undefined}>
            <ul className={styles.navList}>{NAV.map(navItem)}</ul>
            <ul className={`${styles.navList} ${styles.navEnd}`}>{NAV_END.map(navItem)}</ul>
          </div>
        </nav>

        <main className={styles.main}>
          {/* Search, notifications and the signed-in officer. */}
          <header className={styles.topbar} data-part="topbar">
            <div className={styles.search} data-popover data-part="search">
              <icons.search className={styles.searchIcon} />
              <input
                type="search"
                role="combobox"
                className={styles.searchField}
                placeholder="Search deals, parties or reports"
                aria-label="Search deals, parties or reports"
                aria-expanded={searchOpen && matches.length > 0}
                aria-controls={`${id}-search`}
                aria-autocomplete="list"
                aria-activedescendant={
                  searchOpen && matches.length ? `${id}-hit-${searchActive}` : undefined
                }
                value={query}
                onFocus={() => setSearchOpen(true)}
                onChange={(event) => {
                  setQuery(event.target.value);
                  setSearchActive(0);
                  setSearchOpen(true);
                }}
                onKeyDown={(event) => {
                  if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
                    event.preventDefault();
                    const move = event.key === 'ArrowDown' ? 1 : -1;
                    setSearchOpen(true);
                    setSearchActive(
                      (index) => (index + move + matches.length) % Math.max(1, matches.length),
                    );
                  } else if (event.key === 'Enter' && searchOpen && matches[searchActive]) {
                    event.preventDefault();
                    const hit = matches[searchActive];
                    setQuery(hit.name);
                    setSearchOpen(false);
                    announce(`${hit.kind} ${hit.name} opened`);
                  }
                }}
              />
              {searchOpen && (
                <ul
                  id={`${id}-search`}
                  role="listbox"
                  aria-label="Results"
                  className={styles.searchList}
                >
                  {matches.length === 0 && (
                    <li className={styles.searchEmpty}>Nothing matches “{query}”</li>
                  )}
                  {matches.map((hit, index) => (
                    <li
                      key={hit.name}
                      id={`${id}-hit-${index}`}
                      role="option"
                      aria-selected={index === searchActive}
                      className={styles.searchHit}
                      onPointerEnter={() => setSearchActive(index)}
                      onClick={() => {
                        setQuery(hit.name);
                        setSearchOpen(false);
                        announce(`${hit.kind} ${hit.name} opened`);
                      }}
                    >
                      <span className={styles.searchKind}>{hit.kind}</span>
                      <span>{hit.name}</span>
                    </li>
                  ))}
                </ul>
              )}
            </div>
            <div className={styles.account}>
              <div className={styles.bellBox} data-popover>
                <button
                  type="button"
                  className={styles.iconButton}
                  aria-expanded={bellOpen}
                  aria-controls={`${id}-bell`}
                  onClick={() => setBellOpen((open) => !open)}
                >
                  <icons.bell className={styles.buttonIcon} />
                  <span className={styles.bellDot} aria-hidden="true" />
                  <span className={styles.hidden}>Notifications, {NOTICES.length} new</span>
                </button>
                {bellOpen && (
                  <div
                    id={`${id}-bell`}
                    className={styles.popover}
                    data-align="end"
                    data-part="notifications"
                  >
                    <p className={styles.popoverTitle}>Notifications</p>
                    <ul className={styles.notices}>
                      {NOTICES.map((notice) => (
                        <li key={notice.title} className={styles.notice}>
                          <span className={styles.noticeDot} aria-hidden="true" />
                          <span>
                            <strong>{notice.title}</strong>
                            <span>{notice.detail}</span>
                          </span>
                        </li>
                      ))}
                    </ul>
                  </div>
                )}
              </div>
              <span className={styles.divider} aria-hidden="true" />
              <span className={styles.avatar} aria-hidden="true">
                {user.name.slice(0, 1)}
              </span>
              <span className={styles.who}>
                <strong>{user.name}</strong>
                <span>{user.role}</span>
              </span>
            </div>
          </header>

          {/* The title, the verdict and the controls. */}
          <div className={styles.heading} data-part="heading">
            <div className={styles.titleBlock}>
              <h2 className={styles.title}>Inspection readiness</h2>
              <p className={styles.verdict}>
                <Badge tone="warn">Nearly ready</Badge>
                <span>If an inspector came today: 9 gaps to close, none past a legal deadline</span>
              </p>
            </div>
            <div className={styles.controls} data-part="controls">
              <Picker
                label="Office"
                value={scope}
                options={SCOPES}
                onChange={chooseScope}
                part="scope"
                defaultOpen={start.open === 'office'}
              />
              <span className={styles.slash} aria-hidden="true">
                /
              </span>
              <div className={styles.periods} role="radiogroup" aria-label="Period">
                {PERIODS.map((item) => (
                  <button
                    key={item.id}
                    type="button"
                    role="radio"
                    aria-checked={period === item.id}
                    className={styles.period}
                    data-demo={`period-${item.id}`}
                    onClick={() => choosePeriod(item.id)}
                  >
                    {item.label}
                  </button>
                ))}
              </div>
              <span className={styles.slash} aria-hidden="true">
                /
              </span>
              <Picker
                label="Year"
                value={year}
                options={YEARS}
                onChange={setYear}
                align="end"
                part="year"
              />
              <button
                type="button"
                className={styles.cta}
                data-state={pack}
                aria-busy={pack === 'building'}
                onClick={buildPack}
                data-part="cta"
              >
                {pack === 'ready' ? (
                  <icons.check className={styles.buttonIcon} />
                ) : pack === 'building' ? (
                  <span className={styles.spinner} aria-hidden="true" />
                ) : (
                  <icons.pack className={styles.buttonIcon} />
                )}
                <span>
                  {pack === 'building'
                    ? 'Building pack…'
                    : pack === 'ready'
                      ? 'Pack ready'
                      : 'Build inspection pack'}
                </span>
              </button>
            </div>
          </div>

          {/* The tabs: what the chart shows. */}
          <div className={styles.tabs} role="tablist" aria-label="Measures" data-part="tabs">
            {METRICS.map((item) => (
              <button
                key={item.id}
                type="button"
                role="tab"
                id={`${id}-tab-${item.id}`}
                aria-selected={tab === item.id}
                aria-controls={`${id}-panel`}
                tabIndex={tab === item.id ? 0 : -1}
                className={styles.tab}
                data-demo={`tab-${item.id}`}
                onClick={() => {
                  onTabChange?.(item.id);
                  chooseTab(item.id);
                }}
                onKeyDown={(event) => {
                  const index = METRICS.indexOf(item);
                  const next =
                    event.key === 'ArrowRight'
                      ? (index + 1) % METRICS.length
                      : event.key === 'ArrowLeft'
                        ? (index - 1 + METRICS.length) % METRICS.length
                        : event.key === 'Home'
                          ? 0
                          : event.key === 'End'
                            ? METRICS.length - 1
                            : -1;
                  if (next < 0) return;
                  event.preventDefault();
                  onTabChange?.(METRICS[next].id);
                  chooseTab(METRICS[next].id);
                  (event.currentTarget.parentElement?.children[next] as HTMLElement)?.focus();
                }}
              >
                {item.tab}
                {item.count !== undefined && <span className={styles.tabCount}>{item.count}</span>}
              </button>
            ))}
          </div>

          <div className={styles.overview}>
            {/* The chart, and the change it is about. */}
            <section
              id={`${id}-panel`}
              role="tabpanel"
              aria-labelledby={`${id}-tab-${tab}`}
              className={styles.chartPanel}
              data-part="chart"
            >
              <div className={styles.chartText}>
                <p className={styles.chartCaption}>
                  {metric.caption}, by {PERIODS.find((item) => item.id === period)?.unit}
                </p>
                <div className={styles.chartHeadline}>
                  <p className={styles.change} data-part="change">
                    {headline}
                  </p>
                  <p className={styles.changeNote}>{sentence}</p>
                </div>
              </div>
              <LollipopChart
                metric={metric}
                points={points}
                picked={chosen}
                shown={shown}
                ready={seen}
                onPick={(index) => {
                  setPicked(index);
                  setHovered(null);
                }}
                onHover={setHovered}
              />
            </section>

            {/* The headline figures. */}
            <section className={styles.kpis} aria-label="Headline figures" data-part="kpi-card">
              <div className={styles.kpi} data-part="kpi">
                <div>
                  <h3 className={styles.kpiLabel}>Cleared before transfer</h3>
                  <p className={styles.kpiValue}>{(cleared * count).toFixed(1)}%</p>
                </div>
                <div className={styles.kpiSide}>
                  <Badge tone={clearedChange >= 0 ? 'good' : 'bad'}>
                    {formatChange(METRICS[0], clearedChange)}
                  </Badge>
                  <p className={styles.kpiNote}>
                    {dealsCleared} of {dealsTotal} deals in {monthName}
                  </p>
                </div>
              </div>
              <div className={styles.kpi} data-part="kpi">
                <div>
                  <h3 className={styles.kpiLabel}>Overdue items</h3>
                  <p className={styles.kpiValue}>{Math.round(2 * count)}</p>
                </div>
                <div className={styles.kpiSide}>
                  <Badge tone="good">−3 vs Sep</Badge>
                  <p className={styles.kpiNote}>Both are owner registers</p>
                </div>
              </div>
              <div className={styles.kpi} data-part="kpi">
                <div>
                  <h3 className={styles.kpiLabel}>Training completed</h3>
                  <p className={styles.kpiValue}>
                    {Math.round(31 * count)} of {AGENTS.length}
                  </p>
                </div>
                <div className={styles.kpiSide}>
                  <Badge tone="warn">{TRAINING_DUE.size} agents due</Badge>
                  <p className={styles.kpiNote}>Refresher course, 2026</p>
                </div>
              </div>
              <div
                className={styles.dots}
                role="img"
                aria-label={`${AGENTS.length - TRAINING_DUE.size} of ${AGENTS.length} agents trained; due: ${[...TRAINING_DUE].join(', ')}`}
                onPointerLeave={() => setAgent(null)}
                data-part="dot-grid"
              >
                {AGENTS.map((name, index) => (
                  <span
                    key={name}
                    className={styles.agentDot}
                    data-due={TRAINING_DUE.has(name) || undefined}
                    data-lit={agent === index || undefined}
                    style={{ ['--i' as string]: index }}
                    onPointerEnter={() => setAgent(index)}
                  />
                ))}
                {agent !== null && (
                  <span
                    className={styles.agentTip}
                    style={{
                      ['--x' as string]: (agent % 17) / 16,
                      ['--row' as string]: Math.floor(agent / 17),
                    }}
                    aria-hidden="true"
                  >
                    <strong>{AGENTS[agent]}</strong>{' '}
                    {TRAINING_DUE.has(AGENTS[agent]) ? 'due' : 'done'}
                  </span>
                )}
              </div>
            </section>
          </div>

          {/* Ask the data a question. */}
          <section className={styles.ask} aria-labelledby={`${id}-ask`} data-part="prompt">
            <button
              type="button"
              className={styles.askHead}
              aria-expanded={askOpen}
              aria-controls={`${id}-ask-body`}
              onClick={() => setAskOpen((open) => !open)}
            >
              <icons.sparkle className={styles.sparkle} />
              <span id={`${id}-ask`}>What would you like to explore next?</span>
              <icons.chevron className={styles.askChevron} />
            </button>
            <div id={`${id}-ask-body`} className={styles.askBody} hidden={!askOpen}>
              <form
                className={styles.promptForm}
                onSubmit={(event: FormEvent) => {
                  event.preventDefault();
                  ask(prompt);
                }}
              >
                <div className={styles.promptBox} data-demo="prompt" data-part="prompt-field">
                  <div ref={mirror} className={styles.promptMirror} aria-hidden="true">
                    {withTokens(prompt)}
                    {'​'}
                  </div>
                  <input
                    ref={field}
                    className={styles.promptField}
                    value={prompt}
                    aria-label="Ask about your figures; type / and a month to point at one"
                    placeholder="Ask about your figures, type / for a month"
                    onChange={(event) => {
                      setPrompt(event.target.value);
                      requestAnimationFrame(syncMirror);
                    }}
                    onKeyUp={syncMirror}
                    onSelect={syncMirror}
                    onScroll={syncMirror}
                  />
                </div>
                <button type="submit" className={styles.promptSend} disabled={!prompt.trim()}>
                  <icons.send className={styles.buttonIcon} />
                  <span className={styles.hidden}>Ask</span>
                </button>
              </form>
              {answer && (
                <div className={styles.answer} aria-live="polite" data-part="answer">
                  {answer === 'thinking' ? (
                    <span className={styles.thinking} aria-label="Working it out">
                      <i />
                      <i />
                      <i />
                    </span>
                  ) : answer.month === 'September' ? (
                    <>
                      <p className={styles.answerLead}>{ANSWER.lead}</p>
                      <ul className={styles.answerRows}>
                        {ANSWER.rows.map((row) => (
                          <li key={row.name}>
                            <strong>{row.name}</strong>
                            <span>{row.detail}</span>
                          </li>
                        ))}
                      </ul>
                    </>
                  ) : (
                    <p className={styles.answerLead}>No late clearances in {answer.month}.</p>
                  )}
                </div>
              )}
            </div>
          </section>

          <div className={styles.cards} data-part="cards">
            {/* Reports filed, as nested discs. */}
            <section
              className={`${styles.card} ${styles.reportsCard}`}
              aria-labelledby={`${id}-reports`}
              data-part="reports-card"
            >
              <div className={styles.cardHead}>
                <div>
                  <h3 id={`${id}-reports`} className={styles.cardTitle}>
                    Reports filed
                  </h3>
                  <p className={styles.cardSub}>Prepared by {PRODUCT}, filed by the officer</p>
                </div>
                <Picker
                  label="Reports period"
                  value={reportsPeriod}
                  options={REPORT_PERIODS}
                  onChange={setReportsPeriod}
                  align="end"
                />
              </div>
              <ReportRings
                all={reports.all}
                kinds={reports.kinds}
                hovered={ring}
                onHover={setRing}
                ready={seen}
              />
              <div className={styles.restricted}>
                <icons.lock className={styles.buttonIcon} />
                <span>Restricted cases</span>
                <strong>1 open</strong>
                <span className={styles.muted}>count only</span>
              </div>
            </section>

            {/* The readiness score. */}
            <section
              className={`${styles.card} ${styles.scoreCard}`}
              aria-labelledby={`${id}-score`}
              data-part="score-card"
            >
              <div className={styles.cardHead}>
                <h3 id={`${id}-score`} className={styles.cardTitle}>
                  Readiness score
                </h3>
                <div className={styles.breakdownBox} data-popover>
                  <button
                    type="button"
                    className={styles.roundButton}
                    aria-expanded={breakdownOpen}
                    aria-controls={`${id}-breakdown`}
                    onClick={() => setBreakdownOpen((open) => !open)}
                  >
                    <icons.arrow className={styles.buttonIcon} />
                    <span className={styles.hidden}>What the score is made of</span>
                  </button>
                  {breakdownOpen && (
                    <div
                      id={`${id}-breakdown`}
                      className={styles.popover}
                      data-align="end"
                      data-part="breakdown"
                    >
                      <p className={styles.popoverTitle}>What the score is made of</p>
                      <ul className={styles.breakdown}>
                        {BREAKDOWN.map((row) => (
                          <li key={row.name}>
                            <span>{row.name}</span>
                            <span
                              className={styles.breakdownBar}
                              style={{ ['--share' as string]: row.score / row.of }}
                            />
                            <strong>
                              {row.score}
                              <span className={styles.muted}>/{row.of}</span>
                            </strong>
                          </li>
                        ))}
                      </ul>
                    </div>
                  )}
                </div>
              </div>
              <p className={styles.score} aria-label={`${score.now} out of 100`}>
                <span>{shownScore}</span>
                <span className={styles.scoreOf}>/100</span>
              </p>
              <div className={styles.gaugeBox}>
                <ScoreGauge score={gaugeScore} />
                <p className={styles.scoreNote}>
                  {score.now >= score.before ? 'Up' : 'Down'} {Math.abs(score.now - score.before)}{' '}
                  points since September
                </p>
              </div>
            </section>

            {/* The client upload funnel. */}
            <section
              className={`${styles.card} ${styles.funnelCard}`}
              aria-labelledby={`${id}-funnel`}
              data-part="funnel-card"
            >
              <div className={styles.cardHead}>
                <h3 id={`${id}-funnel`} className={styles.cardTitle}>
                  Client upload funnel
                </h3>
                <Picker
                  label="Funnel month"
                  value={funnelMonth}
                  options={FUNNEL_MONTHS}
                  onChange={setFunnelMonth}
                  align="end"
                />
              </div>
              <UploadFunnel
                counts={funnel}
                shown={funnelShown}
                picked={funnelPicked}
                ready={seen}
                onPick={setFunnelPicked}
                onHover={setFunnelHover}
              />
              <div className={styles.insight}>
                <icons.bulb className={styles.insightIcon} />
                <p>
                  <strong>{secondRequest}% of uploads need a second request.</strong> Expired IDs
                  are the usual cause.
                </p>
              </div>
            </section>
          </div>
        </main>
      </div>
      <p className={styles.hidden} aria-live="polite">
        {message}
      </p>
      <div className={styles.pointerLayer} aria-hidden="true">
        <DemoPointer
          ref={pointer}
          className={styles.pointer}
          arrowClass={styles.arrow}
          handClass={styles.hand}
        />
      </div>
    </div>
  );
}
```

### readiness-dashboard/readiness-dashboard.module.css

```css
/* Readiness dashboard (readiness-dashboard.tsx): a near-black app frame with a rail, cards a step
   lighter, one periwinkle blue for the data and a pink family for the reports; by day a pale grey
   frame with white cards and the same blue, a shade deeper.

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

   Layout comes from the component's own width (container queries): the full screen from 1180 px,
   then a narrower card of figures, two cards a row, an icon rail, and under 560 px a top bar with
   a menu button and everything stacked.

   Memory. Every value moves by a transform (stems scale, dots, bars and the funnel's pill slide,
   discs scale), so changing a series never lays the page out; the demo pointer is one small layer. */

.root {
  --frame: #f3f3f5;
  --panel: #fff;
  --panel-2: #f5f5f7;
  --control: #ececef;
  --chip: #ececef;
  --column: #ececf0;
  --line: #e4e4e8;
  --line-strong: #d6d6dc;
  --ink: #111113;
  --ink-soft: #3c3c42;
  --ink-2: #6c6c74;
  --ink-3: #9c9ca4;
  --blue: #4c7cf6;
  --blue-soft: rgb(76 124 246 / 0.2);
  --stem: #d9d9df;
  --stem-lit: #8a8a92;
  --solid: #111113;
  --on-solid: #fff;
  --good: #16794a;
  --good-bg: #dcf3e5;
  --bad: #c23a33;
  --bad-bg: #fde3e1;
  --warn: #7f5d00;
  --warn-bg: #f9edbf;
  --token: #7f5d00;
  --token-bg: #f9edbf;
  --ring-1: #f8e4ef;
  --ring-2: #f1c9de;
  --ring-3: #e6a0c7;
  --ring-4: #e2479b;
  --ring-ink: #2a1621;
  --ring-ink-4: #fff;
  --track: #e5e5ea;
  --hatch: #d7d7dd;
  --due: #dcdce2;
  --tip: #fff;
  --hole: #fff;
  --shadow: 0 12px 32px rgb(20 20 30 / 0.12), 0 2px 6px rgb(20 20 30 / 0.06);
  --focus: #3b73f5;
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  position: relative;
  container-type: inline-size;
  width: 100%;
  min-width: 0;
  color: var(--ink);
  font-family: Outfit, 'Avenir Next', Avenir, 'Segoe UI', system-ui, sans-serif;
  font-size: 14px;
  line-height: 1.35;
  text-align: left;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --frame: light-dark(#f3f3f5, #060606);
    --panel: light-dark(#fff, #141416);
    --panel-2: light-dark(#f5f5f7, #1c1c1e);
    --control: light-dark(#ececef, #232326);
    --chip: light-dark(#ececef, #28272c);
    --column: light-dark(#ececf0, #1b1b1d);
    --line: light-dark(#e4e4e8, #242427);
    --line-strong: light-dark(#d6d6dc, #38383c);
    --ink: light-dark(#111113, #f4f4f5);
    --ink-soft: light-dark(#3c3c42, #cfcfd3);
    --ink-2: light-dark(#6c6c74, #8f8f95);
    --ink-3: light-dark(#9c9ca4, #66666c);
    --blue: light-dark(#4c7cf6, #7ba4fe);
    --blue-soft: light-dark(rgb(76 124 246 / 0.2), rgb(123 164 254 / 0.25));
    --stem: light-dark(#d9d9df, #303030);
    --stem-lit: light-dark(#8a8a92, #b6b5bb);
    --solid: light-dark(#111113, #fff);
    --on-solid: light-dark(#fff, #0b0b0c);
    --good: light-dark(#16794a, #5fdc8e);
    --good-bg: light-dark(#dcf3e5, #16301f);
    --bad: light-dark(#c23a33, #ff8079);
    --bad-bg: light-dark(#fde3e1, #3a1b1a);
    --warn: light-dark(#7f5d00, #f2d36b);
    --warn-bg: light-dark(#f9edbf, #2d2a15);
    --token: light-dark(#7f5d00, #ecc759);
    --token-bg: light-dark(#f9edbf, #36311a);
    --ring-1: light-dark(#f8e4ef, #372531);
    --ring-2: light-dark(#f1c9de, #523346);
    --ring-3: light-dark(#e6a0c7, #6f435e);
    --ring-4: light-dark(#e2479b, #ff8fd1);
    --ring-ink: light-dark(#2a1621, #fff);
    --ring-ink-4: light-dark(#fff, #1d0b16);
    --track: light-dark(#e5e5ea, #28282c);
    --hatch: light-dark(#d7d7dd, #2c2c30);
    --due: light-dark(#dcdce2, #2a2a2e);
    --tip: light-dark(#fff, #141416);
    --hole: light-dark(#fff, #060606);
    --shadow: light-dark(0 12px 32px rgb(20 20 30 / 0.12), 0 14px 36px rgb(0 0 0 / 0.55));
  }
}
.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 *,
.root *::before,
.root *::after {
  box-sizing: border-box;
}
.root :where(h2, h3, p, ul) {
  margin: 0;
  padding: 0;
}
.root :where(ul) {
  list-style: none;
}
.root :where(button, input) {
  font: inherit;
  color: inherit;
  letter-spacing: inherit;
}
.root :where(button) {
  cursor: pointer;
  background: none;
  border: 0;
  padding: 0;
}
.root :where(button, input, [tabindex]):focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.root svg {
  display: block;
  flex-shrink: 0;
}

.frame {
  display: grid;
  grid-template-columns: 88px minmax(0, 1fr);
  gap: 24px;
  padding: 16px 28px 16px 16px;
  border-radius: 36px;
  background: var(--frame);
}

/* The rail. */
.rail {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 16px 8px 14px;
  border: 1px solid var(--line);
  border-radius: 24px;
  background: var(--panel);
}
.brand {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
}
.logo {
  display: grid;
  place-items: center;
  width: 36px;
  height: 36px;
  border-radius: 10px;
  background: var(--solid);
  color: var(--on-solid);
}
.logoIcon {
  width: 24px;
  height: 24px;
}
.brandName {
  font-size: 13px;
  font-weight: 600;
  letter-spacing: -0.01em;
}
.menuButton {
  display: none;
}
.navLists {
  display: flex;
  flex-direction: column;
  flex: 1;
  width: 100%;
  margin-top: 15px;
}
.navList {
  display: flex;
  flex-direction: column;
  gap: 7px;
  width: 100%;
}
.navEnd {
  margin-top: auto;
  padding-top: 20px;
}
.navItem {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 5px;
  width: 100%;
  height: 58px;
  border-radius: 12px;
  color: var(--ink-2);
  transition:
    background-color 160ms ease,
    color 160ms ease;
}
.navItem:hover {
  color: var(--ink);
}
.navItem[aria-current='page'] {
  background: var(--chip);
  color: var(--ink);
}
.navIcon {
  width: 23px;
  height: 23px;
}
.navLabel {
  font-size: 11px;
}
.navItem[aria-current='page'] .navLabel {
  font-weight: 500;
}

/* The top bar. */
.main {
  min-width: 0;
  padding-top: 4px;
}
.topbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
}
.search {
  position: relative;
  display: flex;
  align-items: center;
  width: min(340px, 100%);
  height: 43px;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: var(--panel);
}
.searchIcon {
  position: absolute;
  left: 13px;
  width: 18px;
  height: 18px;
  color: var(--ink-2);
  pointer-events: none;
}
.searchField {
  width: 100%;
  height: 100%;
  padding: 0 14px 0 39px;
  border: 0;
  border-radius: inherit;
  background: none;
  font-size: 13.5px;
  outline: none;
  -webkit-appearance: none;
  appearance: none;
}
.searchField::placeholder {
  color: var(--ink-2);
}
.searchField::-webkit-search-cancel-button {
  display: none;
}
.search:focus-within {
  border-color: var(--line-strong);
}
.searchList,
.popover,
.pickerList {
  position: absolute;
  z-index: 20;
  top: calc(100% + 8px);
  padding: 6px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--panel);
  box-shadow: var(--shadow);
  animation: drop 160ms ease-out;
}
.searchList {
  left: 0;
  right: 0;
}
.searchHit,
.searchEmpty {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 10px;
  border-radius: 9px;
  font-size: 13px;
}
.searchEmpty {
  color: var(--ink-2);
}
.searchHit {
  cursor: pointer;
}
.searchHit[aria-selected='true'] {
  background: var(--panel-2);
}
.searchKind {
  width: 46px;
  color: var(--ink-3);
  font-size: 11.5px;
}
.account {
  display: flex;
  align-items: center;
  gap: 12px;
  min-width: 0;
}
.bellBox,
.breakdownBox {
  position: relative;
}
.iconButton {
  position: relative;
  display: grid;
  place-items: center;
  width: 43px;
  height: 43px;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: var(--panel-2);
}
.buttonIcon {
  width: 18px;
  height: 18px;
}
.bellDot {
  position: absolute;
  top: 11px;
  right: 12px;
  width: 7px;
  height: 7px;
  border: 1.5px solid var(--panel-2);
  border-radius: 50%;
  background: var(--blue);
}
.divider {
  width: 1px;
  height: 28px;
  background: var(--line);
}
.avatar {
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  border-radius: 9px;
  background:
    radial-gradient(circle at 50% 120%, var(--line-strong) 0 46%, transparent 47%), var(--control);
  color: var(--ink);
  font-size: 15px;
  font-weight: 600;
}
.who {
  display: flex;
  flex-direction: column;
  min-width: 0;
  white-space: nowrap;
}
.who strong {
  font-size: 13.5px;
  font-weight: 500;
}
.who span {
  color: var(--ink-2);
  font-size: 11.5px;
}
.popover {
  width: 280px;
  padding: 12px;
}
.popover[data-align='end'],
.pickerList[data-align='end'] {
  right: 0;
}
.popoverTitle {
  margin-bottom: 8px;
  font-size: 13px;
  font-weight: 600;
}
.notices {
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.notice {
  display: flex;
  gap: 10px;
  padding: 8px;
  border-radius: 9px;
  font-size: 12.5px;
}
.notice:hover {
  background: var(--panel-2);
}
.notice > span:last-child {
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.notice strong {
  font-weight: 500;
}
.notice > span:last-child > span {
  color: var(--ink-2);
  font-size: 12px;
}
.noticeDot {
  flex-shrink: 0;
  width: 7px;
  height: 7px;
  margin-top: 5px;
  border-radius: 50%;
  background: var(--blue);
}

/* The title and the controls. */
.heading {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px 24px;
  margin-top: 28px;
}
.titleBlock {
  min-width: 0;
}
.title {
  font-size: 30px;
  font-weight: 400;
  line-height: 1.15;
  letter-spacing: -0.015em;
}
.verdict {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px 10px;
  margin-top: 2px;
  color: var(--ink-soft);
  font-size: 13.5px;
}
.badge {
  display: inline-flex;
  align-items: center;
  height: 24px;
  padding: 0 10px;
  border-radius: 999px;
  font-size: 12px;
  font-weight: 500;
  white-space: nowrap;
}
.badge[data-tone='good'] {
  background: var(--good-bg);
  color: var(--good);
}
.badge[data-tone='bad'] {
  background: var(--bad-bg);
  color: var(--bad);
}
.badge[data-tone='warn'] {
  background: var(--warn-bg);
  color: var(--warn);
}
.controls {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px 13px;
  flex-shrink: 0;
  margin-top: 9px;
}
.slash {
  color: var(--ink-3);
  font-size: 15px;
}
.periods {
  display: flex;
  gap: 14px;
}
.period {
  color: var(--ink-2);
  font-size: 12.5px;
  transition: color 160ms ease;
}
.period:hover {
  color: var(--ink);
}
.period[aria-checked='true'] {
  color: var(--ink);
  font-weight: 600;
}
.picker {
  position: relative;
}
.pickerButton {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  height: 30px;
  padding: 0 9px 0 10px;
  border-radius: 8px;
  background: var(--control);
  font-size: 12px;
  font-weight: 500;
  white-space: nowrap;
}
.pickerChevron {
  width: 14px;
  height: 14px;
  color: var(--ink-2);
  transition: transform 160ms ease;
}
.pickerButton[aria-expanded='true'] .pickerChevron {
  transform: rotate(180deg);
}
.pickerList {
  min-width: 100%;
  width: max-content;
  outline: none;
}
.pickerOption {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 7px 10px;
  border-radius: 8px;
  font-size: 12.5px;
  cursor: pointer;
}
.pickerOption[data-active] {
  background: var(--panel-2);
}
.pickerList:focus-visible .pickerOption[data-active] {
  box-shadow: inset 0 0 0 1.5px var(--focus);
}
.pickerCheck {
  width: 14px;
  height: 14px;
  color: var(--blue);
}
.cta {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 9px;
  height: 40px;
  min-width: 182px;
  padding: 0 14px;
  border-radius: 12px;
  background: var(--solid);
  color: var(--on-solid);
  font-size: 13px;
  font-weight: 500;
  white-space: nowrap;
  transition: transform 120ms ease;
}
.cta:active {
  transform: scale(0.97);
}
.spinner {
  width: 16px;
  height: 16px;
  border: 2px solid currentColor;
  border-right-color: transparent;
  border-radius: 50%;
  animation: spin 700ms linear infinite;
}

/* The tabs. */
.tabs {
  display: flex;
  gap: 26px;
  margin-top: 22px;
  border-bottom: 1px solid var(--line);
  scrollbar-width: none;
}
.tab {
  position: relative;
  display: inline-flex;
  align-items: baseline;
  gap: 8px;
  padding: 0 0 11px;
  color: var(--ink-2);
  font-size: 14px;
  white-space: nowrap;
  transition: color 160ms ease;
}
.tab:hover {
  color: var(--ink);
}
.tab[aria-selected='true'] {
  color: var(--ink);
  font-weight: 500;
}
.tab::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: -1px;
  height: 2px;
  border-radius: 2px;
  background: var(--ink);
  transform: scaleX(0);
  transition: transform 220ms ease;
}
.tab[aria-selected='true']::after {
  transform: scaleX(1);
}
.tabCount {
  color: var(--ink-2);
  font-size: 11.5px;
  font-weight: 400;
}

/* The chart and the headline card. */
.overview {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 415px;
  gap: 20px;
  margin-top: 20px;
}
.chartPanel {
  display: grid;
  grid-template-columns: 202px minmax(0, 1fr);
  gap: 12px;
  min-width: 0;
  height: 294px;
}
.chartText {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  gap: 12px;
  min-width: 0;
  padding: 2px 0 24px;
}
.chartCaption {
  max-width: 190px;
  color: var(--ink-soft);
  font-size: 13px;
  line-height: 1.45;
}
.change {
  font-size: 38px;
  font-weight: 400;
  line-height: 1.1;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.changeNote {
  margin-top: 6px;
  max-width: 190px;
  color: var(--ink-2);
  font-size: 12.5px;
  line-height: 1.45;
}
.lollipop {
  display: grid;
  grid-template-columns: repeat(var(--columns), minmax(0, 1fr));
  min-width: 0;
  height: 100%;
}
.column {
  position: relative;
  display: grid;
  grid-template-rows: minmax(0, 1fr) auto;
  justify-items: center;
  min-width: 0;
  padding: 36px 0 2px;
}
.column[data-empty] {
  cursor: default;
}
.column:focus-visible {
  outline-offset: -2px;
  border-radius: 26px;
}
.columnBack {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 50%;
  width: min(52px, 100%);
  border-radius: 26px;
  background: var(--column);
  opacity: 0;
  transform: translateX(-50%);
  transition: opacity 180ms ease;
}
.column[data-lit] .columnBack {
  opacity: 1;
}
.plot {
  position: relative;
  width: 100%;
  height: 100%;
}
.stem {
  position: absolute;
  bottom: 0;
  left: calc(50% - 1px);
  width: 2px;
  height: 100%;
  background: var(--stem);
  transform: scaleY(var(--share));
  transform-origin: 50% 100%;
  transition:
    transform 700ms cubic-bezier(0.2, 0.8, 0.2, 1) calc(var(--i) * 28ms),
    background-color 180ms ease;
}
.column[data-lit] .stem {
  background: var(--stem-lit);
}
.rider {
  position: absolute;
  inset: 0;
  transform: translateY(calc((1 - var(--share)) * 100%));
  transition: transform 700ms cubic-bezier(0.2, 0.8, 0.2, 1) calc(var(--i) * 28ms);
}
.dot {
  position: absolute;
  top: 0;
  left: 50%;
  width: 11px;
  height: 11px;
  border-radius: 50%;
  background: var(--blue);
  transform: translate(-50%, -50%);
  transition: box-shadow 180ms ease;
}
.column[data-lit] .dot {
  box-shadow: 0 0 0 3px var(--blue-soft);
}
.bubble {
  position: absolute;
  bottom: calc(100% + 14px);
  left: 50%;
  padding: 4px 8px;
  border-radius: 999px;
  background: var(--solid);
  color: var(--on-solid);
  font-size: 12px;
  font-weight: 600;
  white-space: nowrap;
  opacity: 0;
  transform: translate(-50%, 4px);
  transition:
    opacity 160ms ease,
    transform 160ms ease;
  pointer-events: none;
}
.column[data-lit] .bubble {
  opacity: 1;
  transform: translate(-50%, 0);
}
.chip {
  position: relative;
  display: grid;
  place-items: center;
  width: min(39px, calc(100% - 4px));
  height: 28px;
  border-radius: 14px;
  background: var(--chip);
  color: var(--ink-soft);
  font-size: 12px;
  transition:
    background-color 180ms ease,
    color 180ms ease;
}
.column[data-lit] .chip {
  background: var(--solid);
  color: var(--on-solid);
  font-weight: 600;
}
.column[data-empty] .chip {
  background: none;
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink-3);
}

.kpis {
  display: flex;
  flex-direction: column;
  gap: 20px;
  min-width: 0;
  padding: 22px 22px 22px;
  border: 1px solid var(--line);
  border-radius: 24px;
  background: var(--panel);
}
.kpi {
  display: flex;
  justify-content: space-between;
  gap: 12px;
}
.kpiLabel {
  color: var(--ink-soft);
  font-size: 13px;
  font-weight: 400;
}
.kpiValue {
  margin-top: 6px;
  font-size: 28px;
  line-height: 1.1;
  letter-spacing: -0.01em;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.kpiSide {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  justify-content: space-between;
  gap: 4px;
  text-align: right;
}
.kpiNote {
  color: var(--ink-2);
  font-size: 11.5px;
}
.dots {
  position: relative;
  display: grid;
  grid-template-columns: repeat(17, 1fr);
  justify-items: center;
  row-gap: 5px;
  margin-top: auto;
}
.agentDot {
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: var(--blue);
  opacity: 0;
  transform: scale(0.4);
  transition:
    opacity 300ms ease calc(var(--i) * 18ms + 300ms),
    transform 400ms cubic-bezier(0.3, 1.4, 0.5, 1) calc(var(--i) * 18ms + 300ms);
}
.root[data-ready] .agentDot {
  opacity: 1;
  transform: scale(1);
}
.agentDot[data-due] {
  background: var(--due);
}
.root[data-ready] .agentDot[data-lit] {
  transform: scale(1.45);
  transition-delay: 0ms;
}
.agentTip {
  position: absolute;
  bottom: calc(100% + 8px - var(--row) * 14px);
  left: calc(var(--x) * (100% - 9px) + 4.5px);
  padding: 5px 9px;
  border: 1px solid var(--line-strong);
  border-radius: 999px;
  background: var(--tip);
  box-shadow: var(--shadow);
  color: var(--ink-2);
  font-size: 11.5px;
  white-space: nowrap;
  transform: translateX(calc(var(--x) * -100%));
  pointer-events: none;
}
.agentTip strong {
  color: var(--ink);
  font-weight: 500;
}

/* The prompt. */
.ask {
  margin-top: 20px;
  padding: 9px 12px 9px;
  border: 1px solid var(--line);
  border-radius: 18px;
  background: var(--panel);
}
.askHead {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 2px 4px;
  font-size: 13.5px;
  text-align: left;
}
.askHead > span {
  flex: 1;
}
.sparkle {
  width: 18px;
  height: 18px;
  color: var(--blue);
}
.askChevron {
  width: 16px;
  height: 16px;
  color: var(--ink-2);
  transform: rotate(180deg);
  transition: transform 200ms ease;
}
.askHead[aria-expanded='false'] .askChevron {
  transform: none;
}
.askBody {
  margin-top: 7px;
}
.promptForm {
  position: relative;
}
.promptBox {
  position: relative;
  height: 40px;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: var(--panel-2);
}
.promptBox:focus-within {
  border-color: var(--line-strong);
}
.promptMirror,
.promptField {
  position: absolute;
  inset: 0;
  width: 100%;
  padding: 0 52px 0 13px;
  font-size: 13.5px;
  line-height: 38px;
  white-space: pre;
  overflow: hidden;
}
.promptMirror {
  color: var(--ink);
  pointer-events: none;
  /* The field clips its text before the send button; so does its copy. */
  clip-path: inset(-6px 52px -6px 0);
}
.promptField {
  border: 0;
  background: none;
  color: transparent;
  caret-color: var(--ink);
  outline: none;
}
.promptField::placeholder {
  color: var(--ink-2);
}
.promptField::selection {
  background: var(--blue-soft);
  color: transparent;
}
.token {
  border-radius: 5px;
  background: var(--token-bg);
  box-shadow: 0 0 0 4px var(--token-bg);
  color: var(--token);
}
.promptSend {
  position: absolute;
  top: 6px;
  right: 6px;
  display: grid;
  place-items: center;
  width: 28px;
  height: 28px;
  border-radius: 8px;
  background: var(--solid);
  color: var(--on-solid);
  transition: opacity 160ms ease;
}
.promptSend:disabled {
  opacity: 0.25;
  cursor: default;
}
.answer {
  margin-top: 10px;
  padding: 12px 14px;
  border-radius: 10px;
  background: var(--panel-2);
  font-size: 13px;
  animation: drop 220ms ease-out;
}
.answerLead {
  font-weight: 500;
}
.answerRows {
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin-top: 8px;
}
.answerRows li {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 10px;
}
.answerRows strong {
  min-width: 110px;
  font-weight: 500;
}
.answerRows span {
  color: var(--ink-2);
}
.thinking {
  display: inline-flex;
  gap: 4px;
  padding: 4px 0;
}
.thinking i {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--ink-2);
  animation: blink 900ms ease-in-out infinite;
}
.thinking i:nth-child(2) {
  animation-delay: 150ms;
}
.thinking i:nth-child(3) {
  animation-delay: 300ms;
}

/* The three cards. */
.cards {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 20px;
  margin-top: 20px;
}
.card {
  position: relative;
  display: flex;
  flex-direction: column;
  min-width: 0;
  min-height: 361px;
  padding: 22px 22px 20px;
  border: 1px solid var(--line);
  border-radius: 24px;
  background: var(--panel);
}
.cardHead {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.cardTitle {
  font-size: 17px;
  font-weight: 400;
  letter-spacing: -0.005em;
}
.cardSub {
  margin-top: 1px;
  color: var(--ink-2);
  font-size: 12px;
}
.muted {
  color: var(--ink-3);
  font-weight: 400;
}

.reports {
  display: flex;
  align-items: center;
  gap: 20px;
  margin: 29px 0 22px;
}
.rings {
  position: relative;
  flex-shrink: 0;
  width: 176px;
  aspect-ratio: 1;
}
.ring {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  transform-origin: 50% 100%;
  transition:
    transform 800ms cubic-bezier(0.2, 0.8, 0.2, 1) calc(var(--i) * 70ms),
    opacity 180ms ease,
    filter 180ms ease;
}
.ring[data-dim] {
  opacity: 0.55;
}
.ring[data-lit] {
  filter: brightness(1.12);
}
.ringLabel {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: flex-start;
  justify-content: center;
  color: var(--ring-ink);
  font-size: 13px;
  font-weight: 600;
  pointer-events: none;
  transition: transform 800ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ringLabel > span {
  transform: translateY(-50%);
}
.ringLabel[data-ring='3'] {
  color: var(--ring-ink-4);
}
.legend {
  display: flex;
  flex-direction: column;
  flex: 1;
  gap: 2px;
  min-width: 0;
}
.legendRow {
  display: grid;
  grid-template-columns: 10px minmax(0, 1fr) auto 18px;
  align-items: center;
  gap: 10px;
  height: 28px;
  font-size: 13.5px;
  transition: opacity 180ms ease;
}
.reports[data-hovered] .legendRow:not([data-lit]) {
  opacity: 0.5;
}
.legendDot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
}
.legendName {
  overflow: hidden;
  color: var(--ink-soft);
  text-overflow: ellipsis;
  white-space: nowrap;
}
.legendShare {
  color: var(--ink-2);
  font-size: 12px;
}
.legendCount {
  grid-column: 4;
  font-weight: 500;
  text-align: right;
}
.restricted {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: 40px;
  margin-top: auto;
  padding: 0 16px;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: var(--panel-2);
  color: var(--ink-soft);
  font-size: 13.5px;
}
.restricted strong {
  margin-left: auto;
  color: var(--ink);
  font-weight: 600;
}
.restricted .muted {
  font-size: 12px;
}

.roundButton {
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  margin: 0 -2px 0 0;
  border: 1px solid var(--line);
  border-radius: 50%;
  color: var(--ink);
  transition: background-color 160ms ease;
}
.roundButton:hover,
.roundButton[aria-expanded='true'] {
  background: var(--panel-2);
}
.breakdown {
  display: flex;
  flex-direction: column;
  gap: 8px;
  font-size: 12.5px;
}
.breakdown li {
  display: grid;
  grid-template-columns: 104px 1fr 44px;
  align-items: center;
  gap: 10px;
}
.breakdown strong {
  font-weight: 500;
  text-align: right;
}
.breakdownBar {
  height: 6px;
  border-radius: 3px;
  background:
    linear-gradient(var(--blue), var(--blue)) 0 0 / calc(var(--share) * 100%) 100% no-repeat,
    var(--track);
}
.score {
  display: flex;
  align-items: baseline;
  gap: 10px;
  margin-top: 17px;
  font-size: 46px;
  line-height: 1;
  letter-spacing: -0.03em;
  font-variant-numeric: tabular-nums;
}
.scoreOf {
  color: var(--ink-3);
  font-size: 21.5px;
  letter-spacing: 0;
}
.gaugeBox {
  position: relative;
  margin: auto -22px -20px;
  overflow: hidden;
  border-radius: 0 0 23px 23px;
}
.gauge {
  width: 100%;
  height: auto;
}
.gaugeTrack,
.gaugeRest,
.gaugeFill {
  fill: none;
  stroke-width: 50;
}
.gaugeTrack {
  stroke: var(--track);
  stroke-linecap: round;
}
.gaugeFill {
  stroke: var(--blue);
  stroke-linecap: round;
}
.hatchBack {
  fill: transparent;
}
.hatchLine {
  stroke: var(--hatch);
  stroke-width: 2.4;
}
.gaugePass {
  fill: var(--hole);
}
.gaugeKnob {
  fill: #fff;
}
.gaugeDrop {
  stroke: var(--stem-lit);
  stroke-width: 1.5;
}
.gaugeCaption {
  fill: var(--ink-2);
  font-size: 15px;
}
.scoreNote {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 20px;
  color: var(--ink-soft);
  font-size: 13.5px;
  text-align: center;
}

/* The funnel. */
.funnel {
  position: relative;
  margin-top: 14px;
}
.funnelSteps {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  height: 196px;
}
.funnelStep {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  min-width: 0;
  padding: 8px 8px 0;
  border-left: 1px solid var(--line);
  border-radius: 8px 8px 0 0;
  text-align: left;
  transition: background-color 180ms ease;
}
.funnelStep:first-child {
  border-left-color: transparent;
}
.funnelStep[data-lit] {
  background: var(--panel-2);
}
.funnelStep:focus-visible {
  outline-offset: -2px;
}
.funnelLabel {
  color: var(--ink-2);
  font-size: 11px;
  white-space: nowrap;
}
.funnelCount {
  color: var(--ink-2);
  font-size: 19px;
  line-height: 1.3;
  font-variant-numeric: tabular-nums;
}
.funnelStep[data-lit] :is(.funnelLabel, .funnelCount) {
  color: var(--ink);
}
.funnelPlot {
  position: absolute;
  top: 56px;
  left: 0;
  right: 0;
  bottom: 0;
  clip-path: inset(-20px 0 0 0);
}
.funnelBar {
  position: absolute;
  inset: 0;
  border-top: 2px solid var(--blue);
  background: repeating-linear-gradient(-45deg, transparent 0 5px, var(--hatch) 5px 6.4px);
  transform: translateY(calc((1 - var(--share)) * 100%));
  transition:
    transform 800ms cubic-bezier(0.2, 0.8, 0.2, 1) calc(var(--i) * 70ms),
    background-color 180ms ease;
}
.funnelStep[data-lit] .funnelBar {
  background: var(--blue);
}
.funnelMark {
  position: absolute;
  top: -12px;
  left: 50%;
  width: 22px;
  height: 5px;
  border-radius: 3px;
  background: var(--blue);
  transform: translateX(-50%);
}
.funnelStep[data-lit] .funnelMark {
  background: var(--solid);
}
.funnelTipLayer {
  position: absolute;
  top: 56px;
  left: 0;
  right: 0;
  bottom: 0;
  pointer-events: none;
}
.funnelTipRider {
  position: absolute;
  inset: 0;
  transform: translateY(calc((1 - var(--share)) * 100%));
  transition: transform 500ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.funnelTip {
  position: absolute;
  top: 14px;
  left: calc(var(--x) * 100%);
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 4px 11px;
  border: 1px solid var(--line-strong);
  border-radius: 999px;
  background: var(--tip);
  color: var(--ink-2);
  font-size: 12px;
  white-space: nowrap;
  transform: translateX(-50%);
  transition: left 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.funnelTip[data-edge='start'] {
  left: 4px;
  transform: none;
}
.funnelTip[data-edge='end'] {
  left: auto;
  right: 4px;
  transform: none;
}
.funnelTip strong {
  color: var(--ink);
  font-weight: 600;
}
.funnelTipBar {
  width: 1px;
  height: 12px;
  background: var(--line-strong);
}
.insight {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  margin-top: 18px;
  padding: 10px 16px;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: var(--panel-2);
  color: var(--ink-2);
  font-size: 12.5px;
  line-height: 1.5;
}
.insight strong {
  color: var(--ink);
  font-weight: 600;
}
.insightIcon {
  width: 20px;
  height: 20px;
  margin-top: 1px;
  color: var(--ink-soft);
}

/* The demo pointer, over everything; one of its glyphs shows at a time. */
.pointerLayer {
  position: absolute;
  inset: 0;
  overflow: hidden;
  border-radius: 36px;
  pointer-events: none;
}
.pointer {
  width: 22px;
  height: 22px;
  z-index: 40;
}
.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;
}

@keyframes drop {
  from {
    opacity: 0;
    transform: translateY(-4px);
  }
}
@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}
@keyframes blink {
  0%,
  100% {
    opacity: 0.25;
  }
  50% {
    opacity: 1;
  }
}

/* Narrower: the chart's words go over it, the figures card narrows, two cards a row. */
@container (max-width: 1179px) {
  .frame {
    padding: 14px 20px 20px 14px;
    gap: 20px;
  }
  .heading {
    flex-wrap: wrap;
    align-items: flex-start;
  }
  .controls {
    flex-shrink: 1;
    min-width: 0;
  }
  .overview {
    grid-template-columns: minmax(0, 1fr) 300px;
    gap: 20px;
  }
  .chartPanel {
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: auto minmax(0, 1fr);
    height: auto;
    gap: 6px;
  }
  .chartText {
    flex-direction: row;
    align-items: flex-end;
    padding: 0;
  }
  .chartHeadline {
    text-align: right;
  }
  .change {
    font-size: 30px;
  }
  .changeNote {
    margin-left: auto;
  }
  .lollipop {
    height: 250px;
  }
  .kpis {
    padding: 18px;
  }
  .kpiValue {
    font-size: 24px;
  }
  .cards {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
  .rings {
    width: 150px;
  }
  .funnelCard {
    grid-column: 1 / -1;
  }
}

/* The figures card under the chart, its three figures side by side. */
@container (max-width: 859px) {
  .overview {
    grid-template-columns: minmax(0, 1fr);
  }
  .kpis {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: 16px 20px;
  }
  .kpi {
    flex-direction: column;
  }
  .kpiSide {
    align-items: flex-start;
    text-align: left;
  }
  .dots {
    grid-column: 1 / -1;
  }
}

/* An icon rail; one card a row. */
@container (max-width: 699px) {
  .frame {
    grid-template-columns: 60px minmax(0, 1fr);
    gap: 14px;
    padding: 10px 12px 14px 10px;
    border-radius: 28px;
  }
  .pointerLayer {
    border-radius: 28px;
  }
  .rail {
    padding: 12px 6px;
    border-radius: 18px;
  }
  .brandName {
    display: none;
  }
  .navLabel {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
  }
  .navItem {
    height: 44px;
  }
  .who {
    display: none;
  }
  .cards {
    grid-template-columns: minmax(0, 1fr);
  }
  .kpis {
    grid-template-columns: minmax(0, 1fr);
  }
  .kpi {
    flex-direction: row;
  }
  .kpiSide {
    align-items: flex-end;
    text-align: right;
  }
  .card {
    min-height: 0;
  }
  .scoreCard {
    min-height: 330px;
  }
}

/* A phone: a top bar with a menu, everything stacked. */
@container (max-width: 559px) {
  .frame {
    grid-template-columns: minmax(0, 1fr);
    gap: 12px;
    padding: 10px;
    border-radius: 22px;
  }
  .pointerLayer {
    border-radius: 22px;
  }
  .rail {
    z-index: 30;
    flex-direction: row;
    justify-content: space-between;
    padding: 8px 8px 8px 10px;
    border-radius: 16px;
  }
  .brand {
    flex-direction: row;
  }
  .logo {
    width: 30px;
    height: 30px;
    border-radius: 8px;
  }
  .logoIcon {
    width: 17px;
    height: 17px;
  }
  .brandName {
    display: inline;
  }
  .menuButton {
    display: grid;
    place-items: center;
    width: 38px;
    height: 38px;
    border-radius: 10px;
    background: var(--panel-2);
  }
  .navLists {
    position: absolute;
    top: calc(100% + 6px);
    left: 0;
    right: 0;
    display: none;
    margin: 0;
    padding: 6px;
    border: 1px solid var(--line);
    border-radius: 16px;
    background: var(--panel);
    box-shadow: var(--shadow);
  }
  .navLists[data-open] {
    display: block;
    animation: drop 160ms ease-out;
  }
  .navList {
    gap: 2px;
  }
  .navEnd {
    margin-top: 6px;
    padding-top: 6px;
    border-top: 1px solid var(--line);
  }
  .navItem {
    flex-direction: row;
    justify-content: flex-start;
    gap: 12px;
    height: 42px;
    padding: 0 12px;
  }
  .navLabel {
    position: static;
    width: auto;
    height: auto;
    clip-path: none;
    font-size: 14px;
  }
  .search {
    flex: 1;
    width: auto;
  }
  .divider,
  .avatar {
    display: none;
  }
  .heading {
    margin-top: 20px;
  }
  .title {
    font-size: 25px;
  }
  .controls {
    gap: 10px 12px;
  }
  .slash,
  .restricted .muted {
    display: none;
  }
  .cta {
    width: 100%;
  }
  .tabs {
    gap: 20px;
    margin-top: 20px;
    overflow-x: auto;
    overscroll-behavior-x: contain;
  }
  .tabs::-webkit-scrollbar {
    display: none;
  }
  .chartText {
    flex-direction: column;
    align-items: stretch;
  }
  .chartHeadline {
    text-align: left;
  }
  .changeNote {
    margin-left: 0;
    max-width: none;
  }
  .lollipop {
    height: 220px;
  }
  .column {
    padding-top: 32px;
  }
  .chip {
    width: calc(100% - 2px);
    height: 24px;
    font-size: 10px;
  }
  .bubble {
    font-size: 11px;
    padding: 3px 6px;
  }
  .kpiValue {
    font-size: 22px;
  }
  .ask {
    margin-top: 16px;
  }
  .promptMirror,
  .promptField {
    font-size: 13px;
  }
  .card {
    padding: 18px;
  }
  .gaugeBox {
    margin: auto -18px -18px;
  }
  .reports {
    flex-direction: column;
    align-items: stretch;
    gap: 18px;
    margin-top: 20px;
  }
  .rings {
    align-self: center;
    width: 150px;
  }
  .restricted {
    gap: 8px;
    padding: 10px 14px;
    font-size: 12.5px;
  }
  .legendRow {
    gap: 8px;
    grid-template-columns: 8px minmax(0, 1fr) auto 18px;
    font-size: 12.5px;
  }
  .legendDot {
    width: 8px;
    height: 8px;
  }
  .funnelStep {
    padding: 8px 6px 0;
  }
  .funnelCount {
    font-size: 17px;
  }
  .funnelLabel {
    white-space: normal;
    font-size: 10.5px;
    line-height: 1.2;
    min-height: 2.4em;
  }
  .funnelPlot,
  .funnelTipLayer {
    top: 64px;
  }
  .funnelTip {
    font-size: 11.5px;
    padding: 5px 10px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .root *,
  .root *::before,
  .root *::after {
    transition-duration: 0ms !important;
    transition-delay: 0ms !important;
    animation-duration: 0ms !important;
  }
  .spinner {
    animation: none;
  }
}
```

### readiness-dashboard/charts.tsx

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

'use client';

/*
 * The dashboard's four charts, all HTML and CSS but the gauge (one small SVG):
 *
 * - LollipopChart: a stem and a dot per period. Each column is a radio of a group, so the arrows
 *   walk the months; hovering one lights it like the picked one, with its value in a pill over
 *   the dot. A stem is a full-height line scaled from its foot, a dot a full-height box slid down:
 *   both move by transforms, so a new series glides in without laying anything out.
 * - ReportRings: nested discs standing on one point, each sized by the square root of its count
 *   (so their areas compare), scaled from that point.
 * - ScoreGauge: a wide arc running out of its card: grey up to the pass mark, blue up to the
 *   score, hatched beyond.
 * - UploadFunnel: four steps as hatched bars, each a full-height box slid down; the picked or
 *   hovered step fills blue, with its count and share in a pill.
 */

import { useId, type KeyboardEvent } from 'react';
import {
  FUNNEL_NOUNS,
  FUNNEL_STEPS,
  PASS_MARK,
  REPORT_KINDS,
  formatValue,
  type Metric,
  type Point,
} from './data';
import styles from './readiness-dashboard.module.css';

/** Arrow keys over a group of radios: the index they move to, or null. */
function step(event: KeyboardEvent, index: number, count: number, usable: (i: number) => boolean) {
  const forward = event.key === 'ArrowRight' || event.key === 'ArrowDown';
  const back = event.key === 'ArrowLeft' || event.key === 'ArrowUp';
  let next = index;
  if (event.key === 'Home') next = 0;
  else if (event.key === 'End') next = count - 1;
  else if (forward) next = index + 1;
  else if (back) next = index - 1;
  else return null;
  event.preventDefault();
  const direction = back || event.key === 'End' ? -1 : 1;
  while (next >= 0 && next < count && !usable(next)) next += direction;
  return next >= 0 && next < count ? next : null;
}

export function LollipopChart({
  metric,
  points,
  picked,
  shown,
  ready,
  onPick,
  onHover,
}: {
  metric: Metric;
  points: Point[];
  /** The column picked (by click or keys). */
  picked: number;
  /** The column lit: the hovered one, or the picked one. */
  shown: number;
  /** Before the chart is first on screen, every stem lies flat. */
  ready: boolean;
  onPick: (index: number) => void;
  onHover: (index: number | null) => void;
}) {
  const values = points.flatMap((point) => (point.value === null ? [] : [point.value]));
  const min = Math.min(...values);
  const max = Math.max(...values);
  const range = Math.max(max - min, metric.unit === '%' ? 4 : 4);
  // The lowest value stands a good way up its column, and the highest leaves room for its pill.
  const low = min - range * 0.7;
  const high = max + range * 0.17;
  const usable = (index: number) => points[index]?.value !== null;

  return (
    <div
      className={styles.lollipop}
      role="radiogroup"
      aria-label={metric.caption}
      data-part="month-chart"
      style={{ ['--columns' as string]: points.length }}
      onPointerLeave={() => onHover(null)}
    >
      {points.map((point, index) => {
        const empty = point.value === null;
        const share = empty || !ready ? 0 : ((point.value as number) - low) / (high - low);
        const lit = index === shown;
        return (
          <button
            key={`${point.label}-${index}`}
            type="button"
            role="radio"
            aria-checked={index === picked}
            aria-label={
              empty
                ? `${point.long}: not on record yet`
                : `${point.long}: ${formatValue(metric, point.value as number)}`
            }
            aria-disabled={empty || undefined}
            tabIndex={index === picked ? 0 : -1}
            className={styles.column}
            data-lit={lit || undefined}
            data-empty={empty || undefined}
            data-demo={`column-${index}`}
            style={{ ['--share' as string]: share, ['--i' as string]: index }}
            onPointerEnter={() => onHover(empty ? null : index)}
            onClick={() => !empty && onPick(index)}
            onKeyDown={(event) => {
              const next = step(event, index, points.length, usable);
              if (next === null) return;
              onPick(next);
              (event.currentTarget.parentElement?.children[next] as HTMLElement)?.focus();
            }}
          >
            <span className={styles.columnBack} aria-hidden="true" />
            <span className={styles.plot} aria-hidden="true">
              {!empty && (
                <>
                  <span className={styles.stem} />
                  <span className={styles.rider}>
                    <span className={styles.bubble}>
                      {formatValue(metric, point.value as number)}
                    </span>
                    <span className={styles.dot} />
                  </span>
                </>
              )}
            </span>
            <span className={styles.chip} aria-hidden="true">
              {point.label}
            </span>
          </button>
        );
      })}
    </div>
  );
}

const RING_COLOURS = ['--ring-1', '--ring-2', '--ring-3', '--ring-4'];

export function ReportRings({
  all,
  kinds,
  hovered,
  onHover,
  ready,
}: {
  all: number;
  kinds: [number, number, number];
  hovered: number | null;
  onHover: (index: number | null) => void;
  ready: boolean;
}) {
  const counts = [all, ...kinds];
  // Each disc's width as a share of the outer one: areas compare like the counts.
  const sizes = counts.map((count) => Math.max(0.2, Math.sqrt(count / all)));
  // Each count sits in the band between its disc's top and the next one's; the last in its disc.
  const labels = sizes.map((size, index) => {
    const top = 1 - size;
    const next = index < sizes.length - 1 ? 1 - sizes[index + 1] : 1;
    return index < sizes.length - 1 ? (top + next) / 2 : 1 - size / 2;
  });
  const names = ['All reports', ...REPORT_KINDS];

  return (
    <div
      className={styles.reports}
      data-hovered={hovered ?? undefined}
      onPointerLeave={() => onHover(null)}
    >
      <div className={styles.rings} aria-hidden="true" data-part="rings">
        {sizes.map((size, index) => (
          <span
            key={index}
            className={styles.ring}
            data-ring={index}
            data-lit={hovered === index || undefined}
            data-dim={(hovered !== null && hovered !== index) || undefined}
            style={{
              transform: `scale(${ready ? size : 0})`,
              background: `var(${RING_COLOURS[index]})`,
              ['--i' as string]: index,
            }}
            onPointerEnter={() => onHover(index)}
          />
        ))}
        {labels.map((y, index) => (
          <span
            key={`label-${index}`}
            className={styles.ringLabel}
            data-ring={index}
            style={{ transform: `translateY(${(ready ? y : 1) * 100}%)` }}
          >
            <span>{counts[index]}</span>
          </span>
        ))}
      </div>
      <ul className={styles.legend} data-part="legend">
        {names.map((name, index) => (
          <li
            key={name}
            className={styles.legendRow}
            data-lit={hovered === index || undefined}
            data-demo={`legend-${index}`}
            onPointerEnter={() => onHover(index)}
          >
            <span
              className={styles.legendDot}
              style={{ background: `var(${RING_COLOURS[index]})` }}
              aria-hidden="true"
            />
            <span className={styles.legendName}>{name}</span>
            {index > 0 && (
              <span className={styles.legendShare}>{Math.round((counts[index] / all) * 100)}%</span>
            )}
            <span className={styles.legendCount}>{counts[index]}</span>
          </li>
        ))}
      </ul>
    </div>
  );
}

/** The gauge's arc: a value from 0 to 100 as an angle from straight up, in degrees. */
const angle = (value: number) => -108 + value * 1.53;
const CENTRE = { x: 251, y: 375 };
const RADIUS = 320;
const at = (value: number, radius = RADIUS) => {
  const a = (angle(value) * Math.PI) / 180;
  return { x: CENTRE.x + radius * Math.sin(a), y: CENTRE.y - radius * Math.cos(a) };
};
const arc = (from: number, to: number) => {
  const a = at(from);
  const b = at(to);
  const large = Math.abs(angle(to) - angle(from)) > 180 ? 1 : 0;
  return `M${a.x.toFixed(2)} ${a.y.toFixed(2)}A${RADIUS} ${RADIUS} 0 ${large} 1 ${b.x.toFixed(2)} ${b.y.toFixed(2)}`;
};

export function ScoreGauge({ score }: { score: number }) {
  const hatch = `hatch${useId().replace(/[^\w-]/g, '')}`;
  const end = at(score);
  const start = at(PASS_MARK);
  const blue = score > PASS_MARK + 0.3;
  return (
    <svg className={styles.gauge} viewBox="0 0 500 262" aria-hidden="true" data-part="gauge">
      <defs>
        <pattern
          id={hatch}
          width="7"
          height="7"
          patternUnits="userSpaceOnUse"
          patternTransform="rotate(45)"
        >
          <rect width="7" height="7" className={styles.hatchBack} />
          <line x1="0" y1="0" x2="0" y2="7" className={styles.hatchLine} />
        </pattern>
      </defs>
      <path d={arc(score, 118)} className={styles.gaugeRest} stroke={`url(#${hatch})`} />
      {blue && <path d={arc(PASS_MARK, score)} className={styles.gaugeFill} />}
      {/* The grey runs over the blue's first end, its round cap closing round the pass mark. */}
      <path d={arc(-12, PASS_MARK)} className={styles.gaugeTrack} />
      <circle cx={start.x} cy={start.y} r="9" className={styles.gaugePass} />
      <text x={start.x} y={start.y + 47} className={styles.gaugeCaption} textAnchor="middle">
        Pass mark {PASS_MARK}
      </text>
      <line x1={end.x} y1={end.y} x2={end.x} y2={end.y + 90} className={styles.gaugeDrop} />
      <circle cx={end.x} cy={end.y} r="10" className={styles.gaugeKnob} />
    </svg>
  );
}

export function UploadFunnel({
  counts,
  shown,
  picked,
  ready,
  onPick,
  onHover,
}: {
  counts: [number, number, number, number];
  shown: number;
  picked: number;
  ready: boolean;
  onPick: (index: number) => void;
  onHover: (index: number | null) => void;
}) {
  const top = counts[0] * 1.11;
  const value = counts[shown];
  const share =
    shown === 0
      ? null
      : `${Math.round((value / counts[shown - 1]) * 100)}% of ${FUNNEL_NOUNS[shown - 1]}`;
  return (
    <div className={styles.funnel} data-part="funnel" onPointerLeave={() => onHover(null)}>
      <div className={styles.funnelSteps} role="radiogroup" aria-label="Upload step">
        {counts.map((count, index) => (
          <button
            key={FUNNEL_STEPS[index]}
            type="button"
            role="radio"
            aria-checked={index === picked}
            tabIndex={index === picked ? 0 : -1}
            className={styles.funnelStep}
            data-lit={index === shown || undefined}
            data-demo={`funnel-${index}`}
            style={{ ['--share' as string]: ready ? count / top : 0, ['--i' as string]: index }}
            onPointerEnter={() => onHover(index)}
            onClick={() => onPick(index)}
            onKeyDown={(event) => {
              const next = step(event, index, counts.length, () => true);
              if (next === null) return;
              onPick(next);
              (event.currentTarget.parentElement?.children[next] as HTMLElement)?.focus();
            }}
          >
            <span className={styles.funnelLabel}>{FUNNEL_STEPS[index]}</span>
            <span className={styles.funnelCount}>{count}</span>
            <span className={styles.funnelPlot} aria-hidden="true">
              <span className={styles.funnelBar}>
                <span className={styles.funnelMark} />
              </span>
            </span>
          </button>
        ))}
      </div>
      <div
        className={styles.funnelTipLayer}
        style={{ ['--share' as string]: ready ? value / top : 0 }}
        aria-hidden="true"
      >
        <div className={styles.funnelTipRider}>
          <div
            className={styles.funnelTip}
            data-part="funnel-tip"
            data-edge={shown === 0 ? 'start' : shown === counts.length - 1 ? 'end' : undefined}
            style={{ ['--x' as string]: (shown + 0.5) / counts.length }}
          >
            <strong>
              {value} {FUNNEL_NOUNS[shown]}
            </strong>
            {share && (
              <>
                <span className={styles.funnelTipBar} />
                <span>{share}</span>
              </>
            )}
          </div>
        </div>
      </div>
    </div>
  );
}
```

### readiness-dashboard/picker.tsx

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

'use client';

/*
 * A small select of the dashboard ("Whole firm", "Year 2026", "This year", "October"): a button
 * that opens a listbox under it. The list takes the focus while it is open: arrows, Home and End
 * move through it, Enter or Space picks, Escape closes it and gives the focus back to the button;
 * a press anywhere else or Tab closes it too.
 */

import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react';
import { icons } from './icons';
import styles from './readiness-dashboard.module.css';

export function Picker<T extends string>({
  label,
  value,
  options,
  onChange,
  align = 'start',
  part,
  defaultOpen = false,
}: {
  /** What it picks, for screen readers: "Office". */
  label: string;
  value: T;
  options: readonly T[];
  onChange: (value: T) => void;
  /** Which edge of the button the list lines up with. */
  align?: 'start' | 'end';
  part?: string;
  /** Opens with the list showing, held (without the focus) until the dashboard is used. */
  defaultOpen?: boolean;
}) {
  const [open, setOpen] = useState(defaultOpen);
  const [active, setActive] = useState(() =>
    defaultOpen ? Math.max(0, options.indexOf(value)) : 0,
  );
  const holding = useRef(defaultOpen);
  const button = useRef<HTMLButtonElement>(null);
  const list = useRef<HTMLUListElement>(null);
  const box = useRef<HTMLDivElement>(null);
  const id = useId();

  useEffect(() => {
    if (!open) return;
    if (!holding.current) list.current?.focus({ preventScroll: true });
    const away = (event: PointerEvent) => {
      const target = event.target as Node;
      const screen = box.current?.closest('[data-part="dashboard"]');
      if (holding.current && !screen?.contains(target)) return;
      holding.current = false;
      if (!box.current?.contains(target)) setOpen(false);
    };
    document.addEventListener('pointerdown', away);
    return () => document.removeEventListener('pointerdown', away);
  }, [open]);

  const show = () => {
    setActive(Math.max(0, options.indexOf(value)));
    setOpen(true);
  };
  const pick = (index: number) => {
    onChange(options[index]);
    setOpen(false);
    button.current?.focus({ preventScroll: true });
  };

  const onListKey = (event: KeyboardEvent) => {
    const last = options.length - 1;
    const moves: Record<string, number> = {
      ArrowDown: Math.min(last, active + 1),
      ArrowUp: Math.max(0, active - 1),
      Home: 0,
      End: last,
    };
    if (event.key in moves) {
      event.preventDefault();
      setActive(moves[event.key]);
    } else if (event.key === 'Enter' || event.key === ' ') {
      event.preventDefault();
      pick(active);
    } else if (event.key === 'Escape') {
      event.preventDefault();
      setOpen(false);
      button.current?.focus({ preventScroll: true });
    } else if (event.key === 'Tab') setOpen(false);
  };

  return (
    <div ref={box} className={styles.picker} data-part={part}>
      <button
        ref={button}
        type="button"
        className={styles.pickerButton}
        aria-haspopup="listbox"
        aria-expanded={open}
        aria-controls={open ? `${id}-list` : undefined}
        aria-label={`${label}: ${value}`}
        onClick={() => (open ? setOpen(false) : show())}
        onKeyDown={(event) => {
          if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
            event.preventDefault();
            show();
          }
        }}
      >
        <span>{value}</span>
        <icons.chevron className={styles.pickerChevron} />
      </button>
      {open && (
        <ul
          ref={list}
          id={`${id}-list`}
          role="listbox"
          tabIndex={-1}
          aria-label={label}
          aria-activedescendant={`${id}-${active}`}
          className={styles.pickerList}
          data-align={align}
          onKeyDown={onListKey}
        >
          {options.map((option, index) => (
            <li
              key={option}
              id={`${id}-${index}`}
              role="option"
              aria-selected={option === value}
              data-active={index === active || undefined}
              className={styles.pickerOption}
              onPointerEnter={() => setActive(index)}
              onClick={() => pick(index)}
            >
              <span>{option}</span>
              {option === value && <icons.check className={styles.pickerCheck} />}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
```

### readiness-dashboard/data.ts

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

/*
 * The dashboard's figures: an invented estate agency's compliance year, as its officer sees it.
 * Every series is written out or derived here, so the component only picks and draws.
 */

export const PRODUCT = 'Corvane';

export const MONTHS = [
  'Jan',
  'Feb',
  'Mar',
  'Apr',
  'May',
  'Jun',
  'Jul',
  'Aug',
  'Sep',
  'Oct',
  'Nov',
  'Dec',
] as const;
export const MONTH_NAMES = [
  'January',
  'February',
  'March',
  'April',
  'May',
  'June',
  'July',
  'August',
  'September',
  'October',
  'November',
  'December',
];

export type Period = 'weekly' | 'monthly' | 'yearly';
export type TabId = 'summary' | 'deals' | 'reports' | 'screening' | 'training' | 'gaps';

export type Metric = {
  id: TabId;
  tab: string;
  /** A count beside the tab's name. */
  count?: number;
  /** What the chart shows, before "by month" / "by week" / "by year". */
  caption: string;
  /** Percentages read in points; counts in their own unit. */
  unit: '%' | 'count';
  /** The unit of a count's change: "+3 deals". */
  noun?: string;
  /** Fewer is better (open gaps). */
  lowerIsBetter?: boolean;
  /** "October cleared a higher share than September": the verb, when the value rose or fell. */
  rose: string;
  fell: string;
  /** 2026, January to October (the latest month on record). */
  monthly: number[];
};

export const METRICS: Metric[] = [
  {
    id: 'summary',
    tab: 'Summary',
    caption: 'Share of deals cleared before transfer',
    unit: '%',
    rose: 'cleared a higher share',
    fell: 'cleared a lower share',
    monthly: [82.3, 78.3, 85.3, 86.2, 82.3, 90.2, 89.2, 93.2, 92.3, 95.3],
  },
  {
    id: 'deals',
    tab: 'Deals',
    caption: 'Deals cleared before transfer',
    unit: 'count',
    noun: 'deals',
    rose: 'cleared more deals',
    fell: 'cleared fewer deals',
    monthly: [28, 25, 33, 31, 36, 39, 34, 42, 37, 41],
  },
  {
    id: 'reports',
    tab: 'Reports',
    caption: 'Reports filed inside their deadline',
    unit: '%',
    rose: 'filed more on time',
    fell: 'filed fewer on time',
    monthly: [88.0, 91.5, 86.4, 93.1, 94.0, 92.2, 95.6, 96.8, 94.4, 97.9],
  },
  {
    id: 'screening',
    tab: 'Screening',
    caption: 'Parties screened within a day',
    unit: '%',
    rose: 'screened more within a day',
    fell: 'screened fewer within a day',
    monthly: [91.2, 89.8, 92.6, 90.4, 93.9, 94.7, 93.1, 95.8, 96.4, 96.1],
  },
  {
    id: 'training',
    tab: 'Training',
    caption: 'Agents up to date on training',
    unit: '%',
    rose: 'had more agents up to date',
    fell: 'had fewer agents up to date',
    monthly: [70.6, 73.5, 73.5, 76.5, 79.4, 82.4, 82.4, 85.3, 88.2, 91.2],
  },
  {
    id: 'gaps',
    tab: 'Gaps',
    count: 9,
    caption: 'Gaps open at the end of the period',
    unit: 'count',
    noun: 'gaps',
    lowerIsBetter: true,
    rose: 'left more gaps open',
    fell: 'left fewer gaps open',
    monthly: [21, 19, 18, 17, 15, 16, 13, 12, 11, 9],
  },
];

export const SCOPES = ['Whole firm', 'Harbour office', 'Old Town office'] as const;
export type Scope = (typeof SCOPES)[number];
export const YEARS = ['Year 2026', 'Year 2025', 'Year 2024'] as const;
export type Year = (typeof YEARS)[number];

/** A small, repeatable wobble, so derived series look measured rather than drawn. */
function wobble(seed: number) {
  const x = Math.sin(seed * 12.9898) * 43758.5453;
  return x - Math.floor(x) - 0.5;
}

const round = (value: number, unit: Metric['unit']) =>
  unit === '%' ? Math.round(value * 10) / 10 : Math.max(0, Math.round(value));

/** How an office differs from the whole firm, and how a year differs from the next one. */
const SCOPE_SHIFT: Record<Scope, number> = {
  'Whole firm': 0,
  'Harbour office': -2.4,
  'Old Town office': 1.6,
};
const YEAR_SHIFT: Record<Year, number> = { 'Year 2026': 0, 'Year 2025': -4.2, 'Year 2024': -7.5 };

/** Shifts a value by `points` in the metric's own way: points of a share, or a share of a count. */
function shift(metric: Metric, value: number, points: number, seed: number) {
  const direction = metric.lowerIsBetter ? -1 : 1;
  if (metric.unit === '%')
    return Math.min(99.6, round(value + points + wobble(seed) * 2.2, metric.unit));
  return round(value * (1 + (direction * points) / 40) + wobble(seed) * 2, metric.unit);
}

export type Point = { label: string; long: string; value: number | null };

/** The chart's columns for a tab, a period, an office and a year; null where nothing is on record yet. */
export function seriesFor(metric: Metric, period: Period, scope: Scope, year: Year): Point[] {
  const scopeShift = SCOPE_SHIFT[scope];
  const tab = METRICS.indexOf(metric);
  if (period === 'monthly') {
    const yearShift = YEAR_SHIFT[year];
    const current = year === 'Year 2026';
    return MONTHS.map((label, index) => {
      let value: number | null = null;
      if (index < metric.monthly.length) {
        value = metric.monthly[index];
        if (scopeShift || yearShift)
          value = shift(metric, value, scopeShift + yearShift, index + tab * 13 + scopeShift * 7);
      } else if (!current) {
        // An earlier year has November and December: they carry on from October.
        const last = metric.monthly[metric.monthly.length - 1];
        value = shift(metric, last, scopeShift + yearShift + (index - 9) * 0.6, index + tab * 5);
      }
      return { label, long: `${MONTH_NAMES[index]}${current ? '' : ` ${year.slice(5)}`}`, value };
    });
  }
  if (period === 'weekly') {
    // The last twelve weeks up to the latest: weeks 30 to 41 of 2026.
    const latest = metric.monthly[metric.monthly.length - 1];
    const earlier = metric.monthly[metric.monthly.length - 4];
    return Array.from({ length: 12 }, (_, index) => {
      const t = index / 11;
      const base = earlier + (latest - earlier) * t;
      const value = shift(metric, base, scopeShift + wobble(index * 3 + tab) * 3, index + tab * 11);
      return { label: `W${30 + index}`, long: `week ${30 + index}`, value };
    });
  }
  // Year by year, 2019 to 2026 (the year so far).
  const latest = metric.monthly.reduce((sum, value) => sum + value, 0) / metric.monthly.length;
  return Array.from({ length: 8 }, (_, index) => {
    const back = 7 - index;
    const value = shift(metric, latest, scopeShift - back * 1.9, index * 5 + tab * 3);
    return { label: String(2019 + index), long: String(2019 + index), value };
  });
}

/** How a value reads: "95.3%" or "41". */
export const formatValue = (metric: Metric, value: number) =>
  metric.unit === '%' ? `${value.toFixed(1)}%` : String(value);

/** A change: "+3.0 pts" or "-2 gaps". */
export function formatChange(metric: Metric, change: number) {
  const sign = change > 0 ? '+' : change < 0 ? '−' : '±';
  const size = Math.abs(change);
  return metric.unit === '%'
    ? `${sign}${size.toFixed(1)} pts`
    : `${sign}${size} ${metric.noun ?? ''}`.trim();
}

/** The agents, for the training dots: 31 of 34 have done this year's refresher. */
export const AGENTS = [
  'Ines Marlow',
  'Tobias Renn',
  'Dara Okafor',
  'Lena Brask',
  'Hugo Varga',
  'Maya Lindqvist',
  'Felix Aranda',
  'Noor Haddad',
  'Pia Kestrel',
  'Jonas Weir',
  'Ada Sorensen',
  'Rafael Duarte',
  'Clara Moss',
  'Theo Brandt',
  'Yara Nunes',
  'Oskar Hale',
  'Selma Ruiz',
  'Ivo Castell',
  'Mina Okoro',
  'Leo Farrow',
  'Zoe Achterberg',
  'Ravi Menon',
  'Elin Strand',
  'Marco Bellini',
  'Sana Qureshi',
  'Paul Ferreira',
  'Iris Volkov',
  'Nils Hagen',
  'Lucia Romero',
  'Ben Aldridge',
  'Hana Sato',
  'Emil Novak',
  'Greta Lund',
  'Omar Saleh',
];
/** Who still owes the refresher. */
export const TRAINING_DUE = new Set(['Emil Novak', 'Greta Lund', 'Omar Saleh']);

export type ReportsPeriod = 'This year' | 'This quarter' | 'Last year';
export const REPORT_PERIODS: ReportsPeriod[] = ['This year', 'This quarter', 'Last year'];
/** Reports filed: all of them, then each kind. Each kind is a part of all. */
export const REPORTS: Record<ReportsPeriod, { all: number; kinds: [number, number, number] }> = {
  'This year': { all: 17, kinds: [11, 4, 2] },
  'This quarter': { all: 6, kinds: [4, 1, 1] },
  'Last year': { all: 21, kinds: [13, 6, 2] },
};
/** Threshold transaction reports, suspicious transaction reports, and screening-match reports. */
export const REPORT_KINDS = ['TTR', 'STR', 'Match reports'];

export const FUNNEL_MONTHS = ['August', 'September', 'October'] as const;
export type FunnelMonth = (typeof FUNNEL_MONTHS)[number];
export const FUNNEL_STEPS = ['Links sent', 'Opened', 'Uploaded', 'Complete first go'];
export const FUNNEL: Record<FunnelMonth, [number, number, number, number]> = {
  August: [92, 79, 60, 47],
  September: [88, 70, 61, 45],
  October: [96, 81, 67, 49],
};
/** The tooltip's words for each step: what the count is, and what it is a share of. */
export const FUNNEL_NOUNS = ['links sent', 'opened', 'uploads', 'complete'];

/** The readiness score, per office, and where it stood a month before. */
export const SCORE: Record<Scope, { now: number; before: number }> = {
  'Whole firm': { now: 86, before: 82 },
  'Harbour office': { now: 79, before: 77 },
  'Old Town office': { now: 91, before: 85 },
};
export const PASS_MARK = 60;

export const NOTICES = [
  { title: 'Screening match to review', detail: 'Party on deal 2026-118, opened 2 h ago' },
  { title: 'Owner register overdue', detail: 'Deal 2026-104, due last Friday' },
  { title: 'Refresher due for 3 agents', detail: 'Training closes on 31 October' },
];

/** What the search finds: deals, parties and reports. */
export const SEARCHABLE = [
  { kind: 'Deal', name: '2026-118 · 14 Quay Lane' },
  { kind: 'Deal', name: '2026-104 · 3 Orchard Row' },
  { kind: 'Deal', name: '2026-097 · 61 Mill Street' },
  { kind: 'Party', name: 'Halden Property Trust' },
  { kind: 'Party', name: 'Agnes Whitlock' },
  { kind: 'Party', name: 'Corran Holdings' },
  { kind: 'Report', name: 'Suspicion report, September' },
  { kind: 'Report', name: 'Threshold report 2026-31' },
];

/** The assistant's answer to the prompt the demo types. */
export const ANSWER = {
  lead: '3 agents cleared deals late in September.',
  rows: [
    { name: 'Tobias Renn', detail: '2 deals, 1 day late on average' },
    { name: 'Dara Okafor', detail: '1 deal, owner register missing' },
    { name: 'Hugo Varga', detail: '1 deal, ID expired on upload' },
  ],
};
```

### readiness-dashboard/icons.tsx

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

/*
 * The dashboard's line icons, drawn on a 24-unit grid with round 1.6-unit strokes. They are
 * decorative (aria-hidden); their size comes from the stylesheet, their colour from currentColor.
 */

import type { ReactNode } from 'react';

function Icon({ children, className }: { children: ReactNode; className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.6"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      focusable="false"
    >
      {children}
    </svg>
  );
}

/** A gear: eight rounded teeth around a ring, worked out once. */
const GEAR = (() => {
  const teeth = 8;
  const points: string[] = [];
  for (let i = 0; i < teeth * 4; i += 1) {
    const angle = (i / (teeth * 4)) * Math.PI * 2 - Math.PI / 2;
    const r = i % 4 === 0 || i % 4 === 1 ? 9.2 : 7.2;
    points.push(
      `${(12 + Math.cos(angle) * r).toFixed(2)} ${(12 + Math.sin(angle) * r).toFixed(2)}`,
    );
  }
  return `M${points.join('L')}Z`;
})();

type Props = { className?: string };

export const icons = {
  key: ({ className }: Props) => (
    <Icon className={className}>
      <circle cx="15" cy="9" r="4.5" />
      <path d="M11.8 12.2 4.5 19.5M6.8 17.2l2 2M8.9 15.1l1.6 1.6" />
      <circle cx="16" cy="8" r="0.6" fill="currentColor" />
    </Icon>
  ),
  queue: ({ className }: Props) => (
    <Icon className={className}>
      <path d="M9 4.5H7.5a2 2 0 0 0-2 2V18a2 2 0 0 0 2 2H11M15 4.5h1.5a2 2 0 0 1 2 2V12" />
      <rect x="9" y="3" width="6" height="3" rx="1" />
      <path d="M8.5 10h7M8.5 13.5h4M13.5 18l2 2 4-4" />
    </Icon>
  ),
  deals: ({ className }: Props) => (
    <Icon className={className}>
      <path d="M4.5 10.2 12 4l7.5 6.2V18a2 2 0 0 1-2 2h-11a2 2 0 0 1-2-2z" />
      <path d="M9 14.5c1.6 1.4 4.4 1.4 6 0" />
    </Icon>
  ),
  reports: ({ className }: Props) => (
    <Icon className={className}>
      <path d="M13 4.5H7a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h1.5M5 9.5h3M5 14h3" />
      <path d="M11.5 19.5V17a3.5 3.5 0 0 1 3.5-3.5h3.5v4a2 2 0 0 1-2 2z" />
      <circle cx="17" cy="7.5" r="3.5" />
      <path d="m15.6 7.6 1 1 1.9-2" />
    </Icon>
  ),
  cases: ({ className }: Props) => (
    <Icon className={className}>
      <rect x="5" y="10.5" width="14" height="10" rx="2.5" />
      <path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5" />
      <circle cx="12" cy="15.5" r="0.7" fill="currentColor" />
    </Icon>
  ),
  lists: ({ className }: Props) => (
    <Icon className={className}>
      <path d="M12 3.5 19 6v5.5c0 4.3-3 7.6-7 9-4-1.4-7-4.7-7-9V6z" />
      <path d="m9 12 2.2 2.2L15.5 10" />
    </Icon>
  ),
  readiness: ({ className }: Props) => (
    <Icon className={className}>
      <rect x="4" y="4" width="16" height="16" rx="4" />
      <path d="M9 15.5v-3M12 15.5v-7M15 15.5V11" />
    </Icon>
  ),
  settings: ({ className }: Props) => (
    <Icon className={className}>
      <path d={GEAR} />
      <circle cx="12" cy="12" r="2.8" />
    </Icon>
  ),
  help: ({ className }: Props) => (
    <Icon className={className}>
      <circle cx="12" cy="12" r="8.5" />
      <path d="M9.6 9.6a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.1-2.4 3.6" />
      <circle cx="12" cy="16.9" r="0.5" fill="currentColor" />
    </Icon>
  ),
  search: ({ className }: Props) => (
    <Icon className={className}>
      <circle cx="11" cy="11" r="6.5" />
      <path d="m16 16 3.5 3.5" />
    </Icon>
  ),
  bell: ({ className }: Props) => (
    <Icon className={className}>
      <path d="M6.5 16.5V11a5.5 5.5 0 0 1 11 0v5.5l1.5 1.5H5z" />
      <path d="M10.2 20.5a2 2 0 0 0 3.6 0M12 3.5v2" />
    </Icon>
  ),
  chevron: ({ className }: Props) => (
    <Icon className={className}>
      <path d="m7.5 10 4.5 4.5 4.5-4.5" />
    </Icon>
  ),
  sparkle: ({ className }: Props) => (
    <Icon className={className}>
      <path d="M10 4.5c.6 3.6 2 5 5.5 5.5-3.5.6-4.9 2-5.5 5.5-.6-3.5-2-4.9-5.5-5.5 3.5-.5 4.9-1.9 5.5-5.5z" />
      <path d="M17 14c.3 1.8 1 2.5 2.7 2.8-1.7.3-2.4 1-2.7 2.7-.3-1.7-1-2.4-2.7-2.7 1.7-.3 2.4-1 2.7-2.8zM17.5 3.5v3M16 5h3" />
    </Icon>
  ),
  pack: ({ className }: Props) => (
    <Icon className={className}>
      <path d="M11 19H5.5a2 2 0 0 1-2-2V6.5a2 2 0 0 1 2-2h3.8l2 2.2h7.2a2 2 0 0 1 2 2V12" />
      <path d="M3.5 9.5h17M14 17.2l2 2 4-4" />
    </Icon>
  ),
  arrow: ({ className }: Props) => (
    <Icon className={className}>
      <path d="M8 16 16 8M9.5 8H16v6.5" />
    </Icon>
  ),
  lock: ({ className }: Props) => (
    <Icon className={className}>
      <rect x="5.5" y="10.5" width="13" height="10" rx="3" />
      <path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5" />
      <circle cx="12" cy="15.5" r="0.7" fill="currentColor" />
    </Icon>
  ),
  bulb: ({ className }: Props) => (
    <Icon className={className}>
      <path d="M9.5 16.5c0-1.6-2.5-3-2.5-5.8a5 5 0 0 1 10 0c0 2.8-2.5 4.2-2.5 5.8z" />
      <path d="M10 19h4M10.8 21.2h2.4M12 2.2v1.3M4.2 5.2l.9.9M19.8 5.2l-.9.9M2.6 11h1.3M20.1 11h1.3" />
    </Icon>
  ),
  menu: ({ className }: Props) => (
    <Icon className={className}>
      <path d="M4.5 7.5h15M4.5 12h15M4.5 16.5h15" />
    </Icon>
  ),
  check: ({ className }: Props) => (
    <Icon className={className}>
      <path d="m6 12.5 4 4 8-8.5" />
    </Icon>
  ),
  close: ({ className }: Props) => (
    <Icon className={className}>
      <path d="m7 7 10 10M17 7 7 17" />
    </Icon>
  ),
  send: ({ className }: Props) => (
    <Icon className={className}>
      <path d="M12 18.5v-13M6.5 11 12 5.5l5.5 5.5" />
    </Icon>
  ),
};

export type IconName = keyof typeof icons;
```

### readiness-dashboard/demo-pointer.tsx

```tsx
'use client';

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

/*
 * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or
 * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends
 * the step in progress at once: its promise rejects, which ends the script right there, and the
 * pointer fades out.
 *
 * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame.
 * The points of the flight are handed to whoever wants to move along with it. The 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 (readiness-dashboard.module.css).
 */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 2,
  display: 'block',
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
  willChange: 'transform',
};

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

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

### readiness-dashboard/use-autoplay.ts

```tsx
'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 };
}
```