# Insight cards

> An agent’s findings, one card at a time, each backed by a live chart you can scrub.

What an agent noticed in a small shop’s numbers, paged one insight at a time. Each insight is a sentence, a card that backs it up and a follow-up question. There are three kinds of card: two flavours compared over a trend chart, a freezer bill spiking on a live chart with a time axis, and the shop’s stock split into segments you can inspect. The charts come in live: a grey line bends into the data. Scrub them with the pointer, a finger or the arrow keys.

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

Category: AI.

## Anatomy

1. **Title and count**: The header’s title and how many insights there are, in a quieter grey.
2. **Pager**: Previous and next, 24 px squares. Paging is instant and wraps around from the last insight to the first.
3. **Sentence**: What the agent noticed, in one or two lines, with @mentions, emphasis and figures in red or green.
4. **Card**: The evidence: a comparison, a spike or an allocation, on its own surface with a hairline edge, at least 278 px tall.
5. **Figures**: Each series’ change in percent, and in money under it, coloured by whether it is good news.
6. **Chart**: A canvas that comes in live: a grey placeholder bends into the data in 620 ms. Scrub it with the pointer, a finger or the arrow keys.
7. **Tooltip**: Reads every visible series at the reading under the cursor, kept 16 px inside the chart.
8. **Follow-up**: The question the agent offers next. Pressing it calls `onFollowUp` with the insight.

## When to use

- An agent reporting what it noticed in someone’s data: a drop, a spike, a lopsided split.
- A handful of findings (two to six) read one at a time, each with the evidence beside the sentence.
- Findings that lead somewhere: each card ends in a follow-up question the reader can ask with one press.
- A side panel or a chat, where a 344 px column fits and a full dashboard would not.

## When not to use

- Monitoring many metrics at once. Use a dashboard where every chart stays in view.
- A single number with no story. Use a stat tile or a metric picker.
- Alerts that need action now: a pager hides all but one. Use a toast or a banner.
- Exact figures to copy or compare row by row. Use a table.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/insight-cards.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 { InsightCards, type Insight } from './insight-cards';

export function AgentFindings({ insights }: { insights: Insight[] }) {
  return (
    <InsightCards
      insights={insights}
      onFollowUp={(insight) => ask(insight.followUp)}
      autoplay={false}
    />
  );
}
```

## States

- **Compare**: Two series side by side, their change and amount, over one trend chart with a legend.
- **Compare, scrubbed**: The cursor snaps to the nearest reading and the tooltip reads both series there.
- **Spike**: One series on a time axis, its newest reading now; the header shows the threshold.
- **Spike, other unit**: Usage picked and the chart scrubbed: the header reads the value under the cursor.
- **Allocation**: A whole split into a bar of segments; the largest is inspected first.
- **Allocation, inspected**: Another segment pressed: the figure, the chip and the note follow it.

## How it works

- The header counts the insights, and its arrows page through them and wrap around. Paging is instant. The new card mounts fresh, so its chart plays its entrance and its toggle and segment start over.
- Each chart is a canvas drawn at the device’s pixel ratio (2 at most) and redrawn only while something moves. Each series is a monotone cubic (Fritsch–Carlson) through its readings, so the curve never overshoots a value. It fades in from transparent at the left edge to full at 40 px. A dashed line at 40% opacity marks the latest value, and a dot sits on it; a live chart’s dot also gets a halo.
- Until a chart is first on screen it shows a grey, gently wavy placeholder line. Then the readings head for their values (time constant 95 ms) while the vertical range opens more slowly (140 ms) from a third of its height. The data is also seen through a Gaussian blur that narrows (85 ms), with the placeholder past both ends. So the line swells in the middle, a low series dips out of sight, the detail comes in last, and everything settles within 620 ms. The line takes its colour over 140 ms, and the dashes, the dot and the axis fade in with it.
- Later changes of value, like the spike’s Spend/Usage toggle, bend the drawn line into the new one over the same 620 ms, on an ease-out quartic.
- The live chart puts its newest reading at the moment the chart was made and steps back one interval (7 s) per reading. It ticks every 10 s (every 20 s when narrow), with the time under each tick fading out within 50 px of either edge. Dotted gridlines sit at round values, but none closer than 10 px to the latest value’s dashes.
- Scrubbing snaps to the nearest reading. A 1 px cursor in the ink colour at 26% stands on it, and an HTML tooltip reads every visible series there, kept 16 px inside the chart. On the spike, the chart’s caption shows the scrubbed value in place of the threshold.
- The compare chart’s legend shows or hides each series. The series fades over 200 ms, and its button dims to 40%.
- The allocation bar is flex segments as wide as their shares. The inspected one goes fully opaque and gets a light inner ring (300 ms), and a 20% white shine grows across it (500 ms, cubic-bezier(0.16, 1, 0.3, 1)); the others rest at 58%. The figure and the note follow the selection, and the card itself never moves.
- While on screen and untouched, a demo pointer plays with each card in turn. It sweeps the charts, switches Spend to Usage and back, and inspects the segments, then pages on. Any input of the reader’s hands it over.
- Every colour is a light-dark() token on the root. The canvas reads its colours from hidden probe elements and reads them again whenever the colour scheme may have changed. The series’ line colours are the same in both schemes. The tooltip is dark in both.

## API reference

- **insights** (`Insight[]`, default `three sample insights`): The insights, in paging order. Each has a summary (text runs, @mentions, figures marked negative or positive, emphasis), a followUp question and a kind. A compare insight has series of { label, tone, values in %, amount }. A spike insight has units of { label, values, format, threshold }, plus interval, total, delta and versus. An allocation insight has segments of { label, short, share, value, tone } and a note.
- **index** (`number`): The insight shown, to control the pager. Without it, the component keeps its own.
- **defaultIndex** (`number`, default `0`): The insight shown first, when uncontrolled.
- **defaultUnit** (`number`): The spike card’s unit picked at first, as an index into its units. Setting it turns the demo off.
- **defaultSegment** (`number`): The allocation card’s segment inspected at first. Setting it turns the demo off.
- **defaultScrub** (`number`): Holds the first card’s chart scrubbed at this share of its width, from 0 to 1. Setting it turns the demo off.
- **onIndexChange** (`(index: number) => void`): Called with the insight the pager goes to.
- **onFollowUp** (`(insight: Insight) => void`): Called when an insight’s follow-up question is pressed.
- **title** (`string`, default `"Insights"`): The header’s title.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer pages through the insights and plays with each card. The charts’ entrance plays either way.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark colours, or the reader’s; without it, the page’s color-scheme.
- **className** (`string`): Added to the root.

## Accessibility

- The cards sit in a labelled section with a real heading. The count reads as “3 insights”, and a polite live region announces “Insight 2 of 3” when the page changes.
- The pager arrows, the legend, the unit toggle, the segments, their chips and the follow-up are real buttons. The toggles, segments and chips report their state with aria-pressed.
- Each chart is a focusable slider. The arrow keys, Page Up and Page Down, Home and End move between readings, and Escape lets go. Its aria-valuetext reads every visible series at the reading, so the canvas, the cursor and the tooltip can stay hidden from assistive technology.
- Every control shows a focus ring for the keyboard. Touch scrubbing keeps vertical scrolling (touch-action: pan-y).
- Under reduced motion the charts are drawn settled at once, nothing transitions and there is no demo.

## Source

### insight-cards/insight-cards.tsx

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

'use client';

import { useEffect, useRef, useState, type CSSProperties, type RefObject } from 'react';
import { INSIGHTS, type Insight, type SummaryPart } from './data';
import { DemoPointer, runDemo, type Demo } from './demo-pointer';
import { TrendChart, TOKEN, type ChartHandle } from './trend-chart';
import { useAutoplay } from './use-autoplay';
import styles from './insight-cards.module.css';

export type { CompareSeries, Insight, Segment, SpikeUnit, SummaryPart, Tone } from './data';
export type { ChartSeries } from './trend-chart';

/*
 * Insight cards: what an agent noticed, one insight at a time. A header counts them and pages
 * through them; each insight is a sentence, a card that backs it up, and a follow-up question.
 *
 * - The cards come in three kinds (data.ts). 'compare' sets two series side by side, their change
 *   in percent and in money, over a trend chart with a legend. 'spike' shows one series on a live
 *   chart with a time axis, in a unit one picks (spend or usage); scrubbing it reads the value in
 *   the chart's header. 'allocation' splits a whole into a bar of segments: pressing a segment, or
 *   its chip, inspects it, and the figure and the note follow.
 * - The charts (trend-chart.tsx) come in live: a grey placeholder line bends into the data as the
 *   card shows, and one scrubs them with the pointer, a finger or the arrow keys.
 * - Paging is instant, and wraps around: the new card's chart plays its entrance.
 * - While on screen and untouched, a demo pointer pages through the insights, scrubs each chart,
 *   switches the unit and inspects the segments. Any input of the reader's hands it over.
 * - defaultUnit, defaultSegment and defaultScrub open the first card with a unit picked, a segment
 *   inspected or the chart held scrubbed, for documentation and tests; any of them turns the demo
 *   off.
 * - 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. The charts read the tokens when it changes.
 */

export function InsightCards({
  insights = INSIGHTS,
  index,
  defaultIndex = 0,
  onIndexChange,
  onFollowUp,
  title = 'Insights',
  autoplay = true,
  defaultUnit,
  defaultSegment,
  defaultScrub,
  colorScheme,
  className = '',
}: {
  /** The insights, in the order the pager goes through them. */
  insights?: Insight[];
  /** The insight shown, to control the pager; without it, the component keeps its own. */
  index?: number;
  /** The insight shown first, when uncontrolled. */
  defaultIndex?: number;
  /** Called with the insight the pager goes to. */
  onIndexChange?: (index: number) => void;
  /** Called when an insight's follow-up question is pressed. */
  onFollowUp?: (insight: Insight) => void;
  /** The header's title. */
  title?: string;
  /** While on screen and untouched, a demo pointer pages through and plays with the cards. */
  autoplay?: boolean;
  /** The spike card's unit picked first (an index into its units). Turns the demo off. */
  defaultUnit?: number;
  /** The allocation card's segment inspected first. Turns the demo off. */
  defaultSegment?: number;
  /** Holds the first card's chart scrubbed at this share of its width (0 to 1). Turns the demo off. */
  defaultScrub?: number;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLElement>(6000);
  const pointer = useRef<HTMLSpanElement>(null);
  const chart = useRef<ChartHandle>(null);

  const total = insights.length;
  const [own, setOwn] = useState(defaultIndex);
  const current = (((index ?? own) % total) + total) % total;
  const insight = insights[current];
  const go = (next: number) => {
    const wrapped = ((next % total) + total) % total;
    if (index === undefined) setOwn(wrapped);
    onIndexChange?.(wrapped);
  };

  // A card's own controls: the spike's unit and the inspected segment, fresh on each insight.
  const [unit, setUnit] = useState(defaultUnit ?? 0);
  const [segment, setSegment] = useState(defaultSegment ?? 0);
  const [scrubbed, setScrubbed] = useState<number | null>(null);
  const [shown, setShown] = useState(insight.id);
  if (shown !== insight.id) {
    setShown(insight.id);
    setUnit(0);
    setSegment(0);
    setScrubbed(null);
  }

  // The demo reads the latest of everything through this ref.
  const live = useRef({ insight, current, go, setUnit, setSegment });
  useEffect(() => {
    live.current = { insight, current, go, setUnit, setSegment };
  });

  // Opening in a given state holds it: the chart scrubbed where asked, and no demo.
  const held =
    defaultUnit !== undefined || defaultSegment !== undefined || defaultScrub !== undefined;
  useEffect(() => {
    if (defaultScrub !== undefined) chart.current?.scrub(defaultScrub);
    // Only on mounting: the reader takes it from there.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const demo = autoplay && playing && !held;
  useEffect(() => {
    const element = root.current;
    const arrow = pointer.current;
    if (!demo || !element || !arrow) return;
    let hovered: HTMLElement | null = null;
    /** Stands in for :hover, as the demo's pointer is not the reader's. */
    const hover = (target: HTMLElement | null) => {
      hovered?.removeAttribute('data-hover');
      hovered = target;
      hovered?.setAttribute('data-hover', '');
    };
    const find = (name: string) => element.querySelector<HTMLElement>(`[data-demo="${name}"]`);
    const click = async ({ moveTo, press, wait }: Demo, name: string, action: () => void) => {
      const target = find(name);
      hover(null);
      await moveTo(target, { x: 0.5, y: 0.55 });
      hover(target);
      await press(action);
      await wait(120);
      hover(null);
    };
    const scrub = async ({ sweep, wait }: Demo, from: number, to: number, ms: number) => {
      hover(null);
      await sweep(find('chart'), { x: from, y: 0.62 }, { x: to, y: 0.58 }, ms, (share) =>
        chart.current?.scrub(share),
      );
      await wait(700);
      chart.current?.scrub(null);
    };
    const stop = runDemo(element, arrow, async (steps) => {
      const { wait } = steps;
      await wait(1500);
      for (;;) {
        const { kind } = live.current.insight;
        if (kind === 'compare') {
          await scrub(steps, 0.06, 0.94, 2600);
          await wait(500);
        } else if (kind === 'spike') {
          await scrub(steps, 0.12, 0.9, 2000);
          await wait(300);
          await click(steps, 'unit-1', () => live.current.setUnit(1));
          await wait(900);
          await scrub(steps, 0.55, 0.95, 900);
          await click(steps, 'unit-0', () => live.current.setUnit(0));
          await wait(600);
        } else {
          await click(steps, 'chip-1', () => live.current.setSegment(1));
          await wait(1300);
          await click(steps, 'segment-2', () => live.current.setSegment(2));
          await wait(1300);
          await click(steps, 'segment-0', () => live.current.setSegment(0));
          await wait(1000);
        }
        await click(steps, 'next', () => live.current.go(live.current.current + 1));
        await wait(1400);
      }
    });
    return () => {
      stop();
      hover(null);
      chart.current?.scrub(null);
    };
    // The script follows the pages itself; it restarts only when the demo starts or stops.
  }, [demo, root]);

  return (
    <section
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="root"
      data-color-scheme={colorScheme}
      aria-label={title}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
    >
      <div className={styles.frame}>
        <div className={styles.column}>
          <div className={styles.header}>
            <span className={styles.heading} data-part="heading">
              <h3 className={styles.title} data-part="title">
                {title}
              </h3>
              <span className={styles.count} data-part="count" aria-label={`${total} insights`}>
                {total}
              </span>
            </span>
            <span className={styles.pager} data-part="pager">
              <button
                type="button"
                className={styles.pageButton}
                data-part="page-button"
                aria-label="Previous insight"
                onClick={() => go(current - 1)}
              >
                <svg viewBox="0 0 24 24" aria-hidden="true">
                  <path d="M14.6 17.6 9 12l5.6-5.6" />
                </svg>
              </button>
              <button
                type="button"
                className={styles.pageButton}
                data-part="page-button"
                aria-label="Next insight"
                data-demo="next"
                onClick={() => go(current + 1)}
              >
                <svg viewBox="0 0 24 24" aria-hidden="true">
                  <path d="M9.4 6.4 15 12l-5.6 5.6" />
                </svg>
              </button>
            </span>
          </div>
          <p className={styles.position} aria-live="polite">
            Insight {current + 1} of {total}
          </p>
          <div key={insight.id} className={styles.page}>
            <p className={styles.summary} data-part="summary">
              {insight.summary.map((part, i) => (
                <Part key={i} part={part} />
              ))}
            </p>
            <div className={styles.card} data-part="card" data-kind={insight.kind}>
              {insight.kind === 'compare' && (
                <>
                  <div className={styles.figures} data-part="figures">
                    {insight.series.map((one) => (
                      <div key={one.label} className={styles.figure}>
                        <span className={styles.figureLabel}>
                          <span className={styles.dot} style={{ background: TOKEN[one.tone] }} />
                          {one.label}
                        </span>
                        <span
                          className={styles.change}
                          data-sentiment={sentiment(one.values[one.values.length - 1])}
                        >
                          {signed(one.values[one.values.length - 1], 2)}%
                        </span>
                        <code className={styles.amount} data-sentiment={sentiment(one.amount)}>
                          {signed(one.amount, 2, '$')}
                        </code>
                      </div>
                    ))}
                  </div>
                  <div className={styles.inset}>
                    <div className={styles.insetBar}>
                      <span className={styles.insetLabel}>{insight.chartLabel}</span>
                      <span className={styles.badge} data-part="badge">
                        {insight.badge}
                      </span>
                    </div>
                    <div data-demo="chart" data-part="chart">
                      <TrendChart
                        ref={chart}
                        variant="compare"
                        label={`${insight.chartLabel}: ${insight.series.map((one) => one.label).join(' and ')}`}
                        series={insight.series}
                        format={(value) => `${signed(value, 2)}%`}
                        onInteract={take}
                      />
                    </div>
                  </div>
                </>
              )}
              {insight.kind === 'spike' && (
                <SpikeCard
                  insight={insight}
                  unit={unit}
                  scrubbed={scrubbed}
                  onUnit={(next) => {
                    take();
                    setUnit(next);
                  }}
                  onScrub={setScrubbed}
                  chart={chart}
                  onInteract={take}
                />
              )}
              {insight.kind === 'allocation' && (
                <AllocationCard
                  insight={insight}
                  selected={segment}
                  onSelect={(next) => {
                    take();
                    setSegment(next);
                  }}
                />
              )}
            </div>
            <button
              type="button"
              className={styles.followUp}
              data-part="follow-up"
              onClick={() => onFollowUp?.(insight)}
            >
              {insight.followUp}
            </button>
          </div>
        </div>
      </div>
      <DemoPointer ref={pointer} />
    </section>
  );
}

/** One run of the sentence. */
function Part({ part }: { part: SummaryPart }) {
  if (typeof part === 'string') return part;
  if ('mention' in part)
    return (
      <span className={styles.mention} data-part="mention">
        <span
          className={styles.mentionDot}
          style={{ background: TOKEN[part.tone ?? 'orange'] }}
          aria-hidden="true"
        />
        @{part.mention}
      </span>
    );
  if ('figure' in part)
    return (
      <code className={styles.inlineFigure} data-part="figure" data-sentiment={part.sentiment}>
        {part.figure}
      </code>
    );
  return <span className={styles.strong}>{part.strong}</span>;
}

/** The spike: its title, the live chart in the unit picked, and the total. */
function SpikeCard({
  insight,
  unit,
  scrubbed,
  onUnit,
  onScrub,
  chart,
  onInteract,
}: {
  insight: Extract<Insight, { kind: 'spike' }>;
  unit: number;
  scrubbed: number | null;
  onUnit: (unit: number) => void;
  onScrub: (index: number | null) => void;
  chart: RefObject<ChartHandle | null>;
  onInteract: () => void;
}) {
  const current = insight.units[unit] ?? insight.units[0];
  return (
    <>
      <div className={styles.spikeBar}>
        <span className={styles.spikeTitle}>
          <svg
            className={styles.spikeArrow}
            viewBox="0 0 24 24"
            style={{ stroke: TOKEN[insight.tone] }}
            aria-hidden="true"
          >
            <path d="M12 19.2V4.8M5.2 11.6 12 4.8l6.8 6.8" />
          </svg>
          {insight.title}
        </span>
        <span className={styles.badge} data-part="badge">
          {insight.badge}
        </span>
      </div>
      <div className={styles.inset}>
        <div className={styles.insetBar}>
          <span className={styles.insetLabel} aria-live="off">
            {scrubbed === null ? current.threshold : current.format(current.values[scrubbed] ?? 0)}
          </span>
          <span className={styles.toggle} data-part="unit-toggle" role="group" aria-label="Unit">
            {insight.units.map((one, i) => (
              <button
                key={one.label}
                type="button"
                className={styles.toggleButton}
                aria-pressed={i === unit}
                data-demo={`unit-${i}`}
                onClick={() => onUnit(i)}
              >
                {one.label}
              </button>
            ))}
          </span>
        </div>
        <div data-demo="chart" data-part="chart">
          <TrendChart
            ref={chart}
            variant="live"
            label={`${insight.title}, ${current.label.toLowerCase()}`}
            series={[{ label: current.label, tone: insight.tone, values: current.values }]}
            format={current.format}
            interval={insight.interval}
            onScrub={onScrub}
            onInteract={onInteract}
          />
        </div>
      </div>
      <div className={styles.total} data-part="total">
        <span className={styles.totalValue}>{insight.total}</span>
        <code className={styles.amount} data-sentiment="negative">
          {insight.delta}
        </code>
        <span className={styles.versus}>{insight.versus}</span>
      </div>
    </>
  );
}

/** The allocation: the inspected segment's figure, the bar of segments, their chips, a note. */
function AllocationCard({
  insight,
  selected,
  onSelect,
}: {
  insight: Extract<Insight, { kind: 'allocation' }>;
  selected: number;
  onSelect: (index: number) => void;
}) {
  const active = insight.segments[selected] ?? insight.segments[0];
  return (
    <>
      <span className={styles.allocationTitle}>
        <span
          className={styles.initial}
          style={{ background: TOKEN[insight.tone] }}
          aria-hidden="true"
        >
          {insight.initial}
        </span>
        {insight.title}
      </span>
      <span className={styles.allocationValue} aria-live="polite">
        ${active.value.toLocaleString('en-US')}
      </span>
      <div
        className={styles.segments}
        data-part="segments"
        role="group"
        aria-label="Allocation segments"
      >
        {insight.segments.map((one, i) => (
          <button
            key={one.label}
            type="button"
            className={styles.segment}
            aria-pressed={i === selected}
            aria-label={`${one.label}: ${one.share}%`}
            data-demo={`segment-${i}`}
            style={{ width: `${one.share}%`, background: TOKEN[one.tone] } as CSSProperties}
            onClick={() => onSelect(i)}
          >
            <span className={styles.segmentShine} />
          </button>
        ))}
      </div>
      <div className={styles.chips} data-part="chips">
        {insight.segments.map((one, i) => (
          <button
            key={one.label}
            type="button"
            className={styles.chip}
            aria-pressed={i === selected}
            data-demo={`chip-${i}`}
            onClick={() => onSelect(i)}
          >
            <span className={styles.chipDot} style={{ background: TOKEN[one.tone] }} />
            {one.short} <span className={styles.tabular}>{one.share}%</span>
          </button>
        ))}
      </div>
      <div className={styles.note} data-part="note">
        <span className={styles.noteTitle} data-tone={active.tone}>
          {active.label}
        </span>
        <span className={styles.noteText}>{insight.note}</span>
      </div>
    </>
  );
}

const sentiment = (value: number) => (value < 0 ? 'negative' : 'positive');

/** A signed figure: "-4.41", "+$617.22". */
function signed(value: number, digits: number, prefix = '') {
  const sign = value < 0 ? '-' : '+';
  const body = Math.abs(value).toLocaleString('en-US', {
    minimumFractionDigits: digits,
    maximumFractionDigits: digits,
  });
  return `${sign}${prefix}${body}`;
}
```

### insight-cards/trend-chart.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useImperativeHandle,
  useLayoutEffect,
  useRef,
  useState,
  type KeyboardEvent,
  type PointerEvent,
  type Ref,
} from 'react';
import type { Tone } from './data';
import styles from './insight-cards.module.css';

/*
 * The insight cards' chart: one or more series drawn on a canvas, with a cursor and a tooltip in
 * HTML over it that one scrubs with the pointer, a finger or the arrow keys.
 *
 * - Two kinds. 'compare' draws series side by side on a shared scale, with a legend to show or
 *   hide each one. 'live' draws one series on a time axis, its newest reading now, and dotted
 *   gridlines at round values.
 * - Each series is a smooth monotone curve through its values (it never overshoots a reading),
 *   fading in from the left edge, with a dashed line at its latest value and a dot on it.
 * - It comes in live: until it is first on screen it shows a grey, gently wavy placeholder line.
 *   Then the line takes its colour within 140 ms while the readings head for their values, the
 *   range opens and a blur over the data narrows, so the line swells, swings out and gains its
 *   detail, settled within 620 ms; the dashes, the dot and the axis fade in with it. New values
 *   bend the drawn line into the new one over the same time.
 * - Scrubbing snaps to the nearest reading: the cursor stands on it and the tooltip, clamped
 *   16 px inside the chart, reads every visible series there.
 * - The canvas is redrawn only while something moves, and at the device's pixel ratio (2 at
 *   most). Its colours come from the CSS tokens, resolved again when the colour scheme changes.
 */

export type ChartSeries = { label: string; tone: Tone; values: number[] };
export type ChartHandle = {
  /** Scrubs to a share of the chart's width (0 to 1), or lets go. For demos. */
  scrub: (share: number | null) => void;
};

type Variant = 'compare' | 'live';

/** The chart's height; its canvas is as tall, and starts lower when a legend sits on top. */
const HEIGHT = 166;

/** Where things go, in px of the canvas, for each kind of chart. */
const GEOMETRY = {
  compare: {
    // The legend's row (16 px and 6 below it) pushes the canvas down; its foot is cut off.
    top: 22,
    // The newest reading's distance from the right edge.
    right: 5,
    // The highest and lowest values' distances from the canvas's top and bottom.
    padTop: 41.25,
    padBottom: 26,
    // The line and its dashes fade in from transparent at fade[0] to full at fade[1]; the line
    // is drawn from clip on.
    fade: [0, 40],
    clip: 0,
    dot: 3,
    halo: 0,
  },
  live: {
    top: 0,
    right: 16,
    padTop: 45,
    padBottom: 32.5,
    fade: [0, 40],
    clip: 27,
    dot: 3.5,
    halo: 6.5,
  },
} as const;

/** The time axis: its rule, its ticks and the middle of its labels. */
const AXIS_Y = 144.5;
const TICK = [145, 149.5];
const LABEL_Y = 159.25;

/** The lines' colours, the same on both schemes. */
const LINE: Record<Tone, string> = {
  orange: '#f68f3c',
  blue: '#3d9aff',
  red: '#ee5c61',
  green: '#3cc07a',
  neutral: '#8b8e96',
  faint: '#5f626a',
};

/** The colour tokens of the dots in the HTML (insight-cards.module.css). */
export const TOKEN: Record<Tone, string> = {
  orange: 'var(--orange)',
  blue: 'var(--accent)',
  red: 'var(--red)',
  green: 'var(--green)',
  neutral: 'var(--line-strong)',
  faint: 'var(--line)',
};

/** The entrance: how long it lasts, when the readings start to move, how fast they and the
 *  range close in and the blur on them narrows (time constants, in ms), how tall the range starts
 *  (a share of the final one), how wide the blur starts (a share of the readings),
 *  and how long the line takes its colour. A later change of values bends over MORPH too. */
const MORPH = 620;
const DELAY = 60;
const VALUE_TAU = 95;
const BLUR_TAU = 85;
const BLUR_FROM = 0.25;
const RANGE_TAU = 140;
const RANGE_FROM = 0.3;
const TINT = 140;
/** How long a series takes to fade in or out from the legend. */
const TOGGLE = 200;

type RGBA = [number, number, number, number];

export function TrendChart({
  series,
  variant,
  format,
  label,
  interval = 7,
  onScrub,
  onInteract,
  ref,
}: {
  series: ChartSeries[];
  variant: Variant;
  /** How a value reads in the tooltip. */
  format: (value: number) => string;
  /** The chart's accessible name. */
  label: string;
  /** For 'live': seconds between two readings. */
  interval?: number;
  /** Called with the scrubbed reading's index, or null when the cursor leaves. */
  onScrub?: (index: number | null) => void;
  /** Called on any input of the reader's: the demo hands over. */
  onInteract?: () => void;
  ref?: Ref<ChartHandle>;
}) {
  const geometry = GEOMETRY[variant];
  const count = Math.max(...series.map((one) => one.values.length));
  const stage = useRef<HTMLDivElement>(null);
  const canvas = useRef<HTMLCanvasElement>(null);
  const tooltip = useRef<HTMLDivElement>(null);
  const probe = useRef<HTMLSpanElement>(null);
  const [width, setWidth] = useState(0);
  const [index, setIndex] = useState<number | null>(null);
  const [hidden, setHidden] = useState<boolean[]>(() => series.map(() => false));
  // The newest reading's time: when the chart was made, as a live feed would have it.
  const [now] = useState(() => Date.now());

  // Everything the drawing reads, kept out of React so a frame needs no render.
  const state = useRef({
    started: false,
    /** When the current bend began, and the lines it starts from (null: the placeholder). */
    morphAt: 0,
    from: null as number[][] | null,
    tinted: false,
    drawn: [] as number[][],
    vis: series.map(() => 1),
    visAt: 0,
    visFrom: series.map(() => 1),
    colors: { label: [0, 0, 0, 1], grid: [0, 0, 0, 1], halo: [0, 0, 0, 1] } as Record<
      'label' | 'grid' | 'halo',
      RGBA
    >,
    font: 'monospace',
    frame: 0,
  });

  const setScrub = useCallback(
    (next: number | null) => {
      setIndex(next);
      onScrub?.(next);
    },
    [onScrub],
  );

  useImperativeHandle(
    ref,
    () => ({
      scrub: (share) =>
        setScrub(share === null ? null : Math.round(Math.min(1, Math.max(0, share)) * (count - 1))),
    }),
    [count, setScrub],
  );

  // The chart's width follows its box.
  useLayoutEffect(() => {
    const element = stage.current;
    if (!element) return;
    const observer = new ResizeObserver(() => setWidth(element.clientWidth));
    observer.observe(element);
    setWidth(element.clientWidth);
    return () => observer.disconnect();
  }, []);

  /** Draws one frame; returns whether something is still moving. */
  const draw = useCallback(
    (time: number) => {
      const element = canvas.current;
      const s = state.current;
      if (!element || !width) return false;
      const ratio = Math.min(2, window.devicePixelRatio || 1);
      if (element.width !== Math.round(width * ratio)) element.width = Math.round(width * ratio);
      if (element.height !== Math.round(HEIGHT * ratio))
        element.height = Math.round(HEIGHT * ratio);
      const ctx = element.getContext('2d');
      if (!ctx) return false;
      ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
      ctx.clearRect(0, 0, width, HEIGHT);

      const step = width / Math.max(1, count - 1);
      const xAt = (i: number) => i * step - geometry.right;
      const all = series.flatMap((one) => one.values);
      const max = Math.max(...all);
      const min = Math.min(...all);
      const span = HEIGHT - geometry.padTop - geometry.padBottom;
      const yOf = (value: number) =>
        max === min
          ? geometry.padTop + span / 2
          : geometry.padTop + ((max - value) / (max - min)) * span;
      const target = series.map((one) => one.values.map(yOf));
      // The placeholder: a gentle wave across the middle, read on a range a fifth as tall.
      const mid = (max + min) / 2;
      const half = (max - min) / 2 || 1;
      const wave = (i: number) => {
        const x = xAt(i);
        return Math.sin(x / 30 + 0.6) + 0.55 * Math.sin(x / 13);
      };
      const near = half * RANGE_FROM;
      const placeholder = (i: number) => mid + ((wave(i) * 3) / span) * 2 * near;

      // The entrance: the readings head for their values quickly while the range opens more
      // slowly, so the line swings out (a low series dips out of sight) and settles as the scale
      // catches up. A later change of values bends the drawn line into the new one.
      const elapsed = s.started ? Math.max(0, time - s.morphAt - DELAY) : 0;
      const done = s.started && time - s.morphAt >= MORPH;
      const valueLeft = done ? 0 : s.started ? Math.exp(-elapsed / VALUE_TAU) : 1;
      const rangeLeft = done ? 0 : s.started ? Math.exp(-elapsed / RANGE_TAU) : 1;
      const blurLeft = done ? 0 : s.started ? Math.exp(-elapsed / BLUR_TAU) : 1;
      const bend = s.started ? 1 - Math.pow(1 - Math.min(1, (time - s.morphAt) / MORPH), 4) : 0;
      let tint = s.tinted ? 1 : 0;
      if (s.started && !s.tinted)
        tint = Math.min(1, Math.max(0, (time - s.morphAt - DELAY) / TINT));
      let shown: number[][];
      if (s.from) {
        const from = s.from;
        shown = target.map((ys, k) =>
          ys.map((y, i) => (from[k]?.[i] ?? y) + (y - (from[k]?.[i] ?? y)) * bend),
        );
      } else {
        const range = half + (near - half) * rangeLeft;
        const hi = mid + range;
        // The detail comes in last: the readings are seen through a blur that narrows, beyond
        // whose ends lies the placeholder, so the line first swells in the middle.
        const sigma = BLUR_FROM * (count - 1) * blurLeft;
        shown = series.map((one) => {
          const n = one.values.length;
          return one.values.map((value, i) => {
            let read = value;
            if (sigma > 0.05) {
              let sum = 0;
              let weight = 0;
              const reach = Math.ceil(sigma * 3);
              for (let j = i - reach; j <= i + reach; j += 1) {
                const inside = j >= 0 && j < n;
                // Beyond the ends weighs less as the blur narrows, so the ends do not hook.
                const w = Math.exp(-((j - i) ** 2) / (2 * sigma * sigma)) * (inside ? 1 : blurLeft);
                sum += w * (inside ? one.values[j] : placeholder(i));
                weight += w;
              }
              read = sum / weight;
            }
            const v = read + (placeholder(i) - read) * valueLeft;
            return geometry.padTop + ((hi - v) / (2 * range)) * span;
          });
        });
      }
      s.drawn = shown;
      // The extras (dashes, dots, axis) come in with the data.
      const extras = s.started ? (s.from ? 1 : 1 - rangeLeft) : 0;

      // The legend's fades.
      const v = Math.min(1, (time - s.visAt) / TOGGLE);
      const vis = hidden.map((off, k) => {
        const end = off ? 0 : 1;
        return s.visFrom[k] + (end - s.visFrom[k]) * v;
      });
      s.vis = vis;

      const rgba = ([r, g, b, a]: RGBA, alpha = 1) => `rgba(${r},${g},${b},${a * alpha})`;

      if (variant === 'live') {
        // Dotted gridlines at round values, but none crowding the latest value's dashes.
        const latest = target[0]?.[target[0].length - 1] ?? 0;
        const raw = (max - min) / 2;
        const unit = Math.pow(10, Math.floor(Math.log10(raw || 1)));
        const nice = [1, 2, 5, 10].map((m) => m * unit).find((m) => m >= raw) ?? unit * 10;
        ctx.save();
        ctx.setLineDash([1, 3]);
        ctx.lineWidth = 1;
        ctx.strokeStyle = rgba(s.colors.grid, extras);
        for (let value = Math.ceil(min / nice) * nice; value <= max; value += nice) {
          const y = Math.round(yOf(value)) + 0.5;
          if (Math.abs(y - latest) < 10 || y < 4 || y > AXIS_Y - 4) continue;
          ctx.beginPath();
          ctx.moveTo(geometry.clip, y);
          ctx.lineTo(width, y);
          ctx.stroke();
        }
        ctx.restore();

        // The time axis: a rule, a tick every 10 s (20 s when narrow) and the time under it.
        ctx.fillStyle = rgba(s.colors.grid, extras);
        ctx.fillRect(0, AXIS_Y - 0.5, width, 1);
        const perSecond = step / interval;
        const every = perSecond * 10 < 56 ? 20 : 10;
        const end = xAt(count - 1);
        const first = Math.ceil((now / 1000 - end / perSecond) / every) * every;
        ctx.font = `11px ${s.font}`;
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        for (let second = first; ; second += every) {
          const x = end - (now / 1000 - second) * perSecond;
          if (x > width + 30) break;
          const px = Math.round(x);
          ctx.fillStyle = rgba(s.colors.grid, extras);
          ctx.fillRect(px - 0.5, TICK[0], 1, TICK[1] - TICK[0]);
          const edge = Math.min(x, width - x);
          const fade = Math.min(1, Math.max(0, (edge - 6) / 44));
          if (fade <= 0) continue;
          const at = new Date(second * 1000);
          const text = [at.getHours(), at.getMinutes(), at.getSeconds()]
            .map((part) => String(part).padStart(2, '0'))
            .join(':');
          ctx.fillStyle = rgba(s.colors.label, fade * extras);
          ctx.fillText(text, x, LABEL_Y);
        }
      }

      const mix = (tone: Tone, alpha: number) => {
        const [r, g, b] = hex(LINE[tone]);
        const [lr, lg, lb, la] = s.colors.label;
        const k = tint;
        return `rgba(${Math.round(lr + (r - lr) * k)},${Math.round(lg + (g - lg) * k)},${Math.round(
          lb + (b - lb) * k,
        )},${(la * 0.55 + (1 - la * 0.55) * k) * alpha})`;
      };

      series.forEach((one, k) => {
        const ys = shown[k];
        const alpha = vis[k];
        if (!ys || alpha <= 0) return;
        const last = ys.length - 1;
        const xEnd = xAt(last);
        const yEnd = ys[last];

        // The dashes at the latest value, fading in from the left.
        ctx.save();
        const dashes = ctx.createLinearGradient(geometry.fade[0], 0, geometry.fade[1], 0);
        dashes.addColorStop(0, mix(one.tone, 0));
        dashes.addColorStop(1, mix(one.tone, 0.4 * extras * alpha));
        ctx.strokeStyle = dashes;
        ctx.lineWidth = 1;
        ctx.setLineDash([4, 4]);
        ctx.beginPath();
        const dashY = Math.round(yEnd * 2) / 2;
        ctx.moveTo(8, dashY);
        ctx.lineTo(width, dashY);
        ctx.stroke();
        ctx.restore();

        // The line.
        ctx.save();
        if (geometry.clip) {
          ctx.beginPath();
          ctx.rect(geometry.clip, 0, width, HEIGHT);
          ctx.clip();
        }
        const stroke = ctx.createLinearGradient(geometry.fade[0], 0, geometry.fade[1], 0);
        stroke.addColorStop(0, mix(one.tone, 0));
        stroke.addColorStop(1, mix(one.tone, alpha));
        ctx.strokeStyle = stroke;
        ctx.lineWidth = 2;
        ctx.lineJoin = 'round';
        ctx.lineCap = 'round';
        ctx.beginPath();
        monotone(
          ctx,
          ys.map((y, i) => [xAt(i), y]),
        );
        ctx.stroke();
        ctx.restore();

        // The dot on the latest value, on a halo for a live chart.
        const dotAlpha = extras * alpha;
        if (dotAlpha <= 0) return;
        if (geometry.halo) {
          ctx.save();
          ctx.shadowColor = `rgba(0,0,0,${0.22 * dotAlpha})`;
          ctx.shadowBlur = 3;
          ctx.fillStyle = rgba(s.colors.halo, dotAlpha);
          ctx.beginPath();
          ctx.arc(xEnd, yEnd, geometry.halo, 0, Math.PI * 2);
          ctx.fill();
          ctx.restore();
        }
        ctx.fillStyle = mix(one.tone, dotAlpha);
        ctx.beginPath();
        ctx.arc(xEnd, yEnd, geometry.dot, 0, Math.PI * 2);
        ctx.fill();
      });

      const morphing = s.started && time - s.morphAt < MORPH;
      const toggling = time - s.visAt < TOGGLE;
      if (s.started && !morphing && !s.tinted) s.tinted = true;
      return morphing || toggling;
    },
    [count, geometry, hidden, interval, now, series, variant, width],
  );

  // Draw, and keep drawing while something moves.
  const kick = useCallback(() => {
    const s = state.current;
    cancelAnimationFrame(s.frame);
    const loop = (time: number) => {
      if (draw(time)) s.frame = requestAnimationFrame(loop);
    };
    s.frame = requestAnimationFrame(loop);
  }, [draw]);

  // The observers below outlive renders, so they call the latest kick through a ref.
  const kickRef = useRef(kick);
  useEffect(() => {
    kickRef.current = kick;
    kick();
  }, [kick]);
  useEffect(() => {
    const s = state.current;
    return () => cancelAnimationFrame(s.frame);
  }, []);

  // New values bend the drawn line into them (a new set of series starts from the placeholder).
  const shape = series.map((one) => one.values.join(',')).join('|');
  const previous = useRef(shape);
  useEffect(() => {
    if (previous.current === shape) return;
    const s = state.current;
    const same = s.drawn.length === series.length;
    previous.current = shape;
    if (!s.started) return;
    s.from = same ? s.drawn.map((ys) => [...ys]) : null;
    if (!same) s.tinted = false;
    s.morphAt = reducedMotion() ? -MORPH : performance.now();
    kick();
  }, [kick, series.length, shape]);

  // The entrance, when the chart is first on screen (at once under reduced motion).
  useEffect(() => {
    const element = stage.current;
    if (!element) return;
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (!entry.isIntersecting) return;
        observer.disconnect();
        const s = state.current;
        s.started = true;
        s.from = null;
        if (reducedMotion()) {
          s.morphAt = -MORPH;
          s.tinted = true;
        } else s.morphAt = performance.now();
        kickRef.current();
      },
      { threshold: 0.2 },
    );
    observer.observe(element);
    return () => observer.disconnect();
  }, []);

  // The colours and the font, from the tokens; again whenever the scheme may have changed.
  useEffect(() => {
    const element = probe.current;
    if (!element) return;
    const read = () => {
      const s = state.current;
      const [label, grid, halo] = [...element.children].map(
        (child) => getComputedStyle(child).color,
      );
      s.colors = { label: resolve(label), grid: resolve(grid), halo: resolve(halo) };
      s.font = getComputedStyle(element).fontFamily || 'monospace';
      kickRef.current();
    };
    read();
    const media = window.matchMedia('(prefers-color-scheme: dark)');
    media.addEventListener('change', read);
    const observer = new MutationObserver(read);
    observer.observe(document.documentElement, { attributes: true });
    const root = element.closest('[data-color-scheme], section');
    if (root) observer.observe(root, { attributes: true, attributeFilter: ['data-color-scheme'] });
    return () => {
      media.removeEventListener('change', read);
      observer.disconnect();
    };
  }, []);

  const toggle = (k: number) => {
    onInteract?.();
    const s = state.current;
    s.visFrom = [...s.vis];
    s.visAt = reducedMotion() ? -TOGGLE : performance.now();
    setHidden((current) => current.map((off, i) => (i === k ? !off : off)));
  };

  // Scrubbing.
  const indexAt = (clientX: number) => {
    const box = stage.current?.getBoundingClientRect();
    if (!box || !box.width) return null;
    const share = Math.min(1, Math.max(0, (clientX - box.left) / box.width));
    return Math.round(share * (count - 1));
  };
  const onPointerMove = (event: PointerEvent) => {
    if (event.pointerType === 'mouse' && event.buttons === 0 && !event.isPrimary) return;
    onInteract?.();
    setScrub(indexAt(event.clientX));
  };
  const onKeyDown = (event: KeyboardEvent) => {
    const current = index ?? count - 1;
    const moves: Record<string, number> = {
      ArrowLeft: current - 1,
      ArrowDown: current - 1,
      ArrowRight: current + 1,
      ArrowUp: current + 1,
      PageDown: current - 8,
      PageUp: current + 8,
      Home: 0,
      End: count - 1,
    };
    if (event.key === 'Escape') {
      setScrub(null);
      return;
    }
    if (!(event.key in moves)) return;
    event.preventDefault();
    setScrub(Math.min(count - 1, Math.max(0, moves[event.key])));
  };

  const visible = series.filter((_, k) => !hidden[k]);
  const reading =
    index === null
      ? ''
      : visible.map((one) => `${one.label} ${format(one.values[index] ?? 0)}`).join(', ');
  const cursorX = index === null ? 0 : (index / Math.max(1, count - 1)) * width;

  // The tooltip stays 16 px inside the chart.
  useLayoutEffect(() => {
    const element = tooltip.current;
    if (!element || index === null) return;
    const half = element.offsetWidth / 2;
    const x = Math.min(Math.max(cursorX, 16 + half), width - 16 - half);
    element.style.left = `${x}px`;
  }, [cursorX, index, width, reading]);

  return (
    <div
      ref={stage}
      className={styles.stage}
      data-variant={variant}
      onPointerMove={onPointerMove}
      onPointerDown={onPointerMove}
      onPointerLeave={(event) => {
        if (event.pointerType !== 'touch') setScrub(null);
      }}
    >
      <span ref={probe} className={styles.chartProbe} aria-hidden="true">
        <span />
        <span />
        <span />
      </span>
      {variant === 'compare' && (
        <div className={styles.legend} data-part="legend">
          {series.map((one, k) => (
            <button
              key={one.label}
              type="button"
              className={styles.legendButton}
              aria-pressed={!hidden[k]}
              aria-label={`${one.label} on the chart`}
              data-off={hidden[k] || undefined}
              onClick={() => toggle(k)}
            >
              <span className={styles.legendDot} style={{ background: LINE[one.tone] }} />
            </button>
          ))}
        </div>
      )}
      <div
        className={styles.scrubber}
        role="slider"
        tabIndex={0}
        aria-label={label}
        aria-valuemin={0}
        aria-valuemax={count - 1}
        aria-valuenow={index ?? count - 1}
        aria-valuetext={
          reading ||
          visible
            .map((one) => `${one.label} ${format(one.values[one.values.length - 1] ?? 0)}`)
            .join(', ')
        }
        onKeyDown={onKeyDown}
        onFocus={() => {
          if (index === null) setScrub(count - 1);
        }}
        onBlur={() => setScrub(null)}
      >
        <canvas
          ref={canvas}
          className={styles.canvas}
          style={{ top: geometry.top }}
          aria-hidden="true"
        />
      </div>
      {index !== null && (
        <>
          <span
            className={styles.cursor}
            data-part="cursor"
            style={{ left: cursorX }}
            aria-hidden="true"
          />
          <div ref={tooltip} className={styles.tooltip} data-part="tooltip" aria-hidden="true">
            {visible.map((one) => (
              <span key={one.label} className={styles.tooltipItem}>
                <span className={styles.tooltipDot} style={{ background: TOKEN[one.tone] }} />
                {format(one.values[index] ?? 0)}
              </span>
            ))}
          </div>
        </>
      )}
    </div>
  );
}

/** Traces a monotone cubic through `points` (Fritsch–Carlson): smooth, and never overshooting. */
function monotone(ctx: CanvasRenderingContext2D, points: number[][]) {
  const n = points.length;
  if (!n) return;
  ctx.moveTo(points[0][0], points[0][1]);
  if (n < 2) return;
  const dx: number[] = [];
  const slope: number[] = [];
  for (let i = 0; i < n - 1; i += 1) {
    dx.push(points[i + 1][0] - points[i][0]);
    slope.push((points[i + 1][1] - points[i][1]) / (dx[i] || 1));
  }
  const tangent = points.map((_, i) => {
    if (i === 0) return slope[0];
    if (i === n - 1) return slope[n - 2];
    const a = slope[i - 1];
    const b = slope[i];
    if (a * b <= 0) return 0;
    const wa = 2 * dx[i] + dx[i - 1];
    const wb = dx[i] + 2 * dx[i - 1];
    return (wa + wb) / (wa / a + wb / b);
  });
  for (let i = 0; i < n - 1; i += 1) {
    const h = dx[i] / 3;
    ctx.bezierCurveTo(
      points[i][0] + h,
      points[i][1] + tangent[i] * h,
      points[i + 1][0] - h,
      points[i + 1][1] - tangent[i + 1] * h,
      points[i + 1][0],
      points[i + 1][1],
    );
  }
}

function hex(value: string): [number, number, number] {
  const n = parseInt(value.slice(1), 16);
  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}

/** Any CSS colour (oklch, color-mix...) as RGBA, by painting it on a pixel. */
let pixel: CanvasRenderingContext2D | null = null;
function resolve(color: string): RGBA {
  pixel ??= document.createElement('canvas').getContext('2d', { willReadFrequently: true });
  if (!pixel || !color.trim()) return [128, 128, 128, 1];
  pixel.clearRect(0, 0, 1, 1);
  pixel.fillStyle = '#000';
  pixel.fillStyle = color.trim();
  pixel.fillRect(0, 0, 1, 1);
  const [r, g, b, a] = pixel.getImageData(0, 0, 1, 1).data;
  return [r, g, b, a / 255];
}

function reducedMotion() {
  return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
```

### insight-cards/insight-cards.module.css

```css
/* Insight cards (insight-cards.tsx, trend-chart.tsx): a soft grey stage, and in its middle a column
   344 px wide at most: the header with its pager, then the insight's sentence, its card and its
   follow-up question.

   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). Shadows that differ between the schemes are
   written once with every layer, the layers a scheme does not use being transparent. The charts'
   canvas reads its colours from the probe at the end of this file. */

.root {
  /* The stage, and the ink in three strengths. */
  --canvas: oklch(96.1% 0.002 247.84);
  --ink: oklch(24.7% 0.006 258.361);
  --ink-2: oklch(50.6% 0.01 264.477);
  --ink-3: oklch(69.5% 0.009 264.505);
  /* Hairlines, a stronger one, and the charts' gridlines. */
  --line: oklch(94.6% 0.003 264.542);
  --line-strong: oklch(91.2% 0.005 258.326);
  --grid: color-mix(in srgb, var(--line) 78%, transparent);
  /* The card; the chart's well, sunk a little; badges and toggles; a control under the pointer. */
  --surface: oklch(100% 0 0);
  --inset: oklch(97.9% 0.002 247.839);
  --field: oklch(96.1% 0.001 286.375);
  --hover: oklch(97% 0.002 247.839);
  /* The figures' colours, and the series' dots. */
  --red: oklch(62.1% 0.192 23.042);
  --green: oklch(60.3% 0.155 150.883);
  --orange: oklch(68.9% 0.179 49.902);
  --accent: oklch(62.6% 0.205 254.947);
  /* The tooltip, dark on both schemes. */
  --tooltip-bg: oklch(27.2% 0.008 264.435);
  --tooltip-fg: oklch(97.6% 0.002 247.839);
  /* The legend's well and its buttons; the halo under a live chart's dot. */
  --legend-bg: rgb(0 0 0 / 0.02);
  --legend-on: rgb(0 0 0 / 0.035);
  --halo: #fff;
  /* A button's edge and lift: a soft halo by day, a short drop at night. */
  --button-edge: oklch(91.2% 0.005 258.326);
  --button-halo: #0000000a;
  --button-drop: transparent;
  /* The same on both schemes: the focus ring, and the demo's pointer, which looks like the
     system's. */
  --focus: #387bff;
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  --sans: var(--body, Inter, ui-sans-serif, system-ui, sans-serif);
  --code: var(--mono, 'JetBrains Mono', ui-monospace, Menlo, monospace);

  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  width: 100%;
  min-height: 437px;
  padding: 12px;
  overflow: hidden;
  overflow: clip;
  border-radius: 14px;
  background: var(--canvas);
  box-shadow: 0 0 0 1px var(--line);
  color: var(--ink);
  font-family: var(--sans);
  font-size: 14px;
  font-feature-settings: 'cv11', 'ss01';
  line-height: 1.5;
  letter-spacing: -0.14px;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --canvas: light-dark(oklch(96.1% 0.002 247.84), oklch(23.1% 0.004 264.487));
    --ink: light-dark(oklch(24.7% 0.006 258.361), oklch(96.4% 0.002 247.839));
    --ink-2: light-dark(oklch(50.6% 0.01 264.477), oklch(73.1% 0.008 260.731));
    --ink-3: light-dark(oklch(69.5% 0.009 264.505), oklch(54.1% 0.01 264.484));
    --line: light-dark(oklch(94.6% 0.003 264.542), oklch(30.8% 0.006 258.354));
    --line-strong: light-dark(oklch(91.2% 0.005 258.326), oklch(35.6% 0.007 264.474));
    --surface: light-dark(oklch(100% 0 0), oklch(26% 0.006 271.191));
    --inset: light-dark(oklch(97.9% 0.002 247.839), oklch(24.3% 0.004 264.492));
    --field: light-dark(oklch(96.1% 0.001 286.375), oklch(29.3% 0.006 271.223));
    --hover: light-dark(oklch(97% 0.002 247.839), oklch(28.9% 0.006 271.22));
    --red: light-dark(oklch(62.1% 0.192 23.042), oklch(66.6% 0.18 21.433));
    --green: light-dark(oklch(60.3% 0.155 150.883), oklch(70.5% 0.154 153.814));
    --orange: light-dark(oklch(68.9% 0.179 49.902), oklch(74.6% 0.156 55.642));
    --accent: light-dark(oklch(62.6% 0.205 254.947), oklch(68% 0.173 253.301));
    --tooltip-bg: light-dark(oklch(27.2% 0.008 264.435), oklch(18.2% 0.004 264.459));
    --tooltip-fg: light-dark(oklch(97.6% 0.002 247.839), oklch(96.4% 0.002 247.839));
    --legend-bg: light-dark(rgb(0 0 0 / 0.02), rgb(255 255 255 / 0.03));
    --legend-on: light-dark(rgb(0 0 0 / 0.035), rgb(255 255 255 / 0.06));
    --halo: light-dark(#fff, #272727);
    --button-edge: light-dark(oklch(91.2% 0.005 258.326), oklch(100% 0 0 / 0.1));
    --button-halo: light-dark(#0000000a, transparent);
    --button-drop: light-dark(transparent, oklch(0% 0 0 / 0.3));
  }
}

/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

.root :focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
:where(.root) button {
  margin: 0;
  border: 0;
  font: inherit;
  letter-spacing: inherit;
  cursor: pointer;
}

/* The column: 344 px at most, in a frame of 480. */
.frame {
  width: 100%;
  max-width: 480px;
}
.column {
  width: 100%;
  max-width: 344px;
  min-height: 408px;
  margin-inline: auto;
}

/* The header: the title and its count, and the pager's two arrows. */
.header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 24px;
}
.heading {
  display: flex;
  align-items: baseline;
  gap: 6px;
}
.title {
  margin: 0;
  font-size: 13px;
  font-weight: 600;
  line-height: 19.5px;
}
.count {
  color: var(--ink-3);
  font-size: 13px;
  line-height: 19.5px;
  font-variant-numeric: tabular-nums;
}
.pager {
  display: flex;
  align-items: center;
  gap: 2px;
}
.pageButton {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 24px;
  padding: 0;
  border-radius: 6px;
  background: transparent;
  color: var(--ink-3);
  transition:
    background-color 100ms cubic-bezier(0.4, 0, 0.2, 1),
    color 100ms cubic-bezier(0.4, 0, 0.2, 1),
    transform 100ms cubic-bezier(0.4, 0, 0.2, 1);
}
.pageButton svg {
  width: 13px;
  height: 13px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.pageButton:active {
  transform: scale(0.96);
}

/* Read out when the page changes; not shown. */
.position {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* The insight's sentence: its mentions, figures and emphasis. */
.summary {
  margin: 6px 0 0;
  color: var(--ink-2);
  font-size: 12.5px;
  line-height: 1.625;
  text-wrap: pretty;
}
.mention {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  vertical-align: baseline;
  color: var(--ink);
  font-weight: 500;
}
.mentionDot {
  display: inline-block;
  width: 10px;
  height: 10px;
  border-radius: 50%;
}
.inlineFigure {
  font-family: var(--code);
  font-size: 11.5px;
  letter-spacing: -0.14px;
}
.strong {
  color: var(--ink);
  font-weight: 500;
}
.root [data-sentiment='negative'] {
  color: var(--red);
}
.root [data-sentiment='positive'] {
  color: var(--green);
}

/* The card. */
.card {
  box-sizing: border-box;
  min-height: 278px;
  margin-top: 8px;
  padding: 12px;
  border-radius: 10px;
  background: var(--surface);
  box-shadow: 0 0 0 1px var(--line);
}

/* Compare: the two series' figures. */
.figures {
  display: flex;
  align-items: center;
  gap: 16px;
}
.figure {
  flex: 1;
  min-width: 0;
  font-size: 14px;
  line-height: 21px;
}
.figureLabel {
  display: flex;
  align-items: center;
  gap: 6px;
  color: var(--ink-2);
  font-size: 11.5px;
  line-height: 17.25px;
}
.dot {
  flex: none;
  width: 8px;
  height: 8px;
  border-radius: 50%;
}
.change {
  display: block;
  font-size: 17px;
  font-weight: 600;
  line-height: 25.5px;
  letter-spacing: -0.17px;
  font-variant-numeric: tabular-nums;
}
.figure .amount {
  display: block;
  line-height: 21.05px;
}
.amount {
  font-family: var(--code);
  font-size: 11.5px;
  letter-spacing: -0.14px;
}

/* The chart's well: a bar with its caption and a badge or a toggle, then the chart. */
.inset {
  margin-top: 8px;
  overflow: hidden;
  border-radius: 8px;
  background: var(--inset);
  box-shadow: 0 0 0 1px var(--line);
}
.insetBar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 6px 10px;
  border-bottom: 1px solid var(--line);
}
.insetLabel {
  color: var(--ink-3);
  font-size: 11px;
  line-height: 16.5px;
  font-variant-numeric: tabular-nums;
}
.badge {
  padding: 2px 8px;
  border-radius: 999px;
  background: var(--field);
  color: var(--ink-2);
  font-size: 10.5px;
  font-weight: 500;
  line-height: 15.75px;
}

/* The chart: a canvas, the legend over its top left, the cursor and the tooltip. */
.stage {
  position: relative;
  height: 166px;
  overflow: hidden;
  touch-action: pan-y;
}
.scrubber {
  position: absolute;
  inset: 0;
  z-index: 1;
}
.root .scrubber:focus-visible {
  outline-offset: -2px;
}
.canvas {
  position: absolute;
  left: 0;
  display: block;
  width: 100%;
  height: 166px;
}
.legend {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 2;
  display: inline-flex;
  gap: 2px;
  padding: 2px;
  border-radius: 6px;
  background: var(--legend-bg);
}
.legendButton {
  display: flex;
  align-items: center;
  padding: 3px 8px;
  border-radius: 4px;
  background: var(--legend-on);
  transition:
    opacity 0.2s,
    background 0.15s;
}
.legendButton[data-off] {
  background: transparent;
  opacity: 0.4;
}
.legendDot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  transition: opacity 0.2s;
}
.legendButton[data-off] .legendDot {
  opacity: 0.4;
}
.cursor {
  position: absolute;
  top: 0;
  bottom: 0;
  z-index: 2;
  width: 1px;
  background: var(--ink);
  opacity: 0.26;
  pointer-events: none;
}
.tooltip {
  position: absolute;
  top: 8px;
  z-index: 3;
  display: flex;
  gap: 12px;
  padding: 5px 9px;
  border: 1px solid var(--line-strong);
  border-radius: 8px;
  background: var(--tooltip-bg);
  box-shadow:
    0 0 0 1px var(--line),
    0 25px 50px rgb(0 0 0 / 0.05),
    0 12px 24px rgb(0 0 0 / 0.04),
    0 6px 12px rgb(0 0 0 / 0.03),
    0 3px 6px rgb(0 0 0 / 0.02),
    0 1.5px 3px rgb(0 0 0 / 0.02);
  color: var(--tooltip-fg);
  white-space: nowrap;
  pointer-events: none;
  transform: translateX(-50%);
}
.tooltipItem {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  font-family: var(--code);
  font-size: 11.5px;
  line-height: 17.25px;
  letter-spacing: -0.14px;
  font-variant-numeric: tabular-nums;
}
.tooltipDot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
}

/* Spike: the title with its arrow, the unit toggle, and the total under the chart. */
.spikeBar {
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.spikeTitle {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 12px;
  font-weight: 500;
  line-height: 18px;
}
.spikeArrow {
  width: 12px;
  height: 12px;
  fill: none;
  stroke-width: 2.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.toggle {
  display: flex;
  padding: 2px;
  border-radius: 999px;
  background: var(--field);
}
.toggleButton {
  padding: 2px 8px;
  border-radius: 999px;
  background: transparent;
  color: var(--ink-3);
  font-size: 10.5px;
  font-weight: 500;
  line-height: 15.75px;
  transition:
    background-color 150ms cubic-bezier(0.4, 0, 0.2, 1),
    color 150ms cubic-bezier(0.4, 0, 0.2, 1),
    box-shadow 150ms cubic-bezier(0.4, 0, 0.2, 1),
    transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.toggleButton[aria-pressed='true'] {
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--button-edge),
    0 0 4px var(--button-halo),
    0 1px 2px var(--button-drop);
  color: var(--ink);
}
.toggleButton:active {
  transform: scale(0.96);
}
.total {
  display: flex;
  align-items: baseline;
  gap: 8px;
  margin-top: 6px;
}
.totalValue {
  font-size: 17px;
  font-weight: 600;
  line-height: 25.5px;
  letter-spacing: -0.17px;
  font-variant-numeric: tabular-nums;
}
.versus {
  color: var(--ink-3);
  font-size: 11px;
  line-height: 16.5px;
}

/* Allocation: the title with its initial, the inspected figure, the segments and their chips. */
.allocationTitle {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 12px;
  font-weight: 500;
  line-height: 18px;
}
.initial {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  color: #fff;
  font-size: 8px;
  font-weight: 700;
  line-height: 12px;
  letter-spacing: 0;
}
.allocationValue {
  display: block;
  margin-top: 4px;
  font-size: 20px;
  font-weight: 600;
  line-height: 30px;
  letter-spacing: -0.2px;
  font-variant-numeric: tabular-nums;
}
.segments {
  box-sizing: border-box;
  display: flex;
  gap: 2px;
  height: 36px;
  margin-top: 12px;
  padding: 2px;
  overflow: hidden;
  border-radius: 999px;
  background: var(--field);
}
.segment {
  position: relative;
  height: 100%;
  padding: 0;
  overflow: hidden;
  border-radius: 999px;
  opacity: 0.58;
  transition:
    opacity 300ms cubic-bezier(0.16, 1, 0.3, 1),
    transform 300ms cubic-bezier(0.16, 1, 0.3, 1),
    box-shadow 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
.segment[aria-pressed='true'] {
  opacity: 1;
  box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.22);
}
.segment:active {
  transform: scale(0.98);
}
.segmentShine {
  position: absolute;
  top: 4px;
  bottom: 4px;
  left: 4px;
  width: 0;
  border-radius: 999px;
  background: rgb(255 255 255 / 0.2);
  opacity: 0;
  transition:
    width 500ms cubic-bezier(0.16, 1, 0.3, 1),
    opacity 500ms cubic-bezier(0.16, 1, 0.3, 1);
}
.segment[aria-pressed='true'] .segmentShine {
  width: calc(100% - 8px);
  opacity: 1;
}
.chips {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px;
  margin-top: 8px;
}
.chip {
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 2px 6px;
  border-radius: 999px;
  background: transparent;
  color: var(--ink-2);
  font-size: 11px;
  line-height: 16.5px;
  transition:
    background-color 150ms cubic-bezier(0.4, 0, 0.2, 1),
    color 150ms cubic-bezier(0.4, 0, 0.2, 1),
    transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.chip[aria-pressed='true'] {
  background: var(--field);
  color: var(--ink);
}
.chip:active {
  transform: scale(0.96);
}
.chipDot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
}
.tabular {
  font-variant-numeric: tabular-nums;
}
.note {
  box-sizing: border-box;
  min-height: 64px;
  margin-top: 12px;
  padding: 8px 10px;
  border-radius: 8px;
  background: var(--inset);
  box-shadow: 0 0 0 1px var(--line);
}
.noteTitle {
  display: block;
  color: var(--ink-2);
  font-size: 11.5px;
  font-weight: 500;
  line-height: 17.25px;
}
.noteTitle[data-tone='orange'] {
  color: var(--orange);
}
.noteTitle[data-tone='blue'] {
  color: var(--accent);
}
.noteTitle[data-tone='green'] {
  color: var(--green);
}
.noteTitle[data-tone='red'] {
  color: var(--red);
}
.noteTitle[data-tone='faint'] {
  color: var(--ink-3);
}
.noteText {
  display: block;
  margin-top: 4px;
  color: var(--ink-3);
  font-size: 11px;
  line-height: 1.625;
}

/* The follow-up question. */
.followUp {
  margin-top: 8px;
  padding: 6px 12px;
  border-radius: 999px;
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--button-edge),
    0 0 4px var(--button-halo),
    0 1px 2px var(--button-drop);
  color: var(--ink);
  font-size: 12px;
  line-height: 18px;
  text-align: left;
  transition:
    color 100ms cubic-bezier(0.4, 0, 0.2, 1),
    background-color 100ms cubic-bezier(0.4, 0, 0.2, 1);
}

/* Hovers: the reader's pointer, where it can hover, or the demo's ([data-hover]). */
@media (hover: hover) {
  .pageButton:hover,
  .chip[aria-pressed='false']:hover {
    background: var(--hover);
    color: var(--ink);
  }
  .toggleButton[aria-pressed='false']:hover {
    color: var(--ink-2);
  }
  .followUp:hover {
    background: var(--hover);
  }
}
.pageButton[data-hover],
.chip[aria-pressed='false'][data-hover] {
  background: var(--hover);
  color: var(--ink);
}
.toggleButton[aria-pressed='false'][data-hover] {
  color: var(--ink-2);
}

/* What the chart's canvas paints with: the axis labels, the gridlines and the dot's halo. */
.chartProbe {
  position: absolute;
  visibility: hidden;
  font-family: var(--code);
}
.chartProbe > :nth-child(1) {
  color: var(--ink-3);
}
.chartProbe > :nth-child(2) {
  color: var(--grid);
}
.chartProbe > :nth-child(3) {
  color: var(--halo);
}

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

### insight-cards/data.ts

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

/*
 * What an insight card shows, and the three sample insights of a small ice-cream shop's agent.
 *
 * An insight is a sentence, a card and a follow-up question. The card is one of three kinds:
 * - 'compare': two series side by side, their change and amount, over a shared trend chart;
 * - 'spike': one series rising past its usual level, on a live chart with a time axis, read in
 *   one of a few units (spend, usage...);
 * - 'allocation': how one whole splits into parts, as a bar of segments one can inspect.
 */

/** The colours a series, a mention or a segment can take. */
export type Tone = 'orange' | 'blue' | 'green' | 'red' | 'neutral' | 'faint';

/** A run of the sentence: plain text, an @mention, a figure (bad news or good), or emphasis. */
export type SummaryPart =
  | string
  | { mention: string; tone?: Tone }
  | { figure: string; sentiment: 'negative' | 'positive' }
  | { strong: string };

export type CompareSeries = {
  /** The series' name, over its figures and in the chart's tooltip. */
  label: string;
  tone: Tone;
  /** Its trend, oldest first, in percent; the last value is its change. */
  values: number[];
  /** Its change in money, shown under the percentage. */
  amount: number;
};

export type SpikeUnit = {
  /** The toggle's label. */
  label: string;
  /** The readings in this unit, oldest first, one every `interval` seconds. */
  values: number[];
  /** How a reading reads, in the chart's header and tooltip. */
  format: (value: number) => string;
  /** What the header says while nothing is scrubbed, e.g. "$2,112 threshold". */
  threshold: string;
};

export type Segment = {
  /** Its name, in full and short. */
  label: string;
  short: string;
  /** Its share of the whole, in percent. */
  share: number;
  /** Its value. */
  value: number;
  tone: Tone;
};

type Base = {
  id: string;
  /** The insight's sentence. */
  summary: SummaryPart[];
  /** The follow-up question offered under the card. */
  followUp: string;
};

export type Insight =
  | (Base & {
      kind: 'compare';
      series: CompareSeries[];
      /** The chart's caption and its badge. */
      chartLabel: string;
      badge: string;
    })
  | (Base & {
      kind: 'spike';
      title: string;
      badge: string;
      tone: Tone;
      units: SpikeUnit[];
      /** Seconds between two readings. */
      interval: number;
      /** The total, its difference and what it is compared with. */
      total: string;
      delta: string;
      versus: string;
    })
  | (Base & {
      kind: 'allocation';
      title: string;
      /** The letter in the title's badge. */
      initial: string;
      tone: Tone;
      segments: Segment[];
      /** A note on the inspected segment. */
      note: string;
    });

const money = (value: number) => `$${Math.round(value).toLocaleString('en-US')}`;

/** The freezer's bill, every seven seconds; its energy use is the same curve in kWh. */
const SPEND = [274, 289, 264, 307, 331, 1210, 1718, 2112];
const KWH_PER_DOLLAR = 82 / 2112;

export const INSIGHTS: Insight[] = [
  {
    id: 'flavors',
    kind: 'compare',
    summary: [
      'The worst performer in your ',
      { mention: 'Creamery', tone: 'orange' },
      ' is Rocky Road — down ',
      { figure: '-6%', sentiment: 'negative' },
      ' or ',
      { figure: '-$2,453.44', sentiment: 'negative' },
      '.',
    ],
    chartLabel: 'Trend snapshot',
    badge: 'Snapshot',
    series: [
      {
        label: 'Mint Chip',
        tone: 'orange',
        amount: -2377.66,
        values: [
          -2.9, -2.94, -2.99, -3.06, -3.13, -3.21, -3.28, -3.34, -3.38, -3.4, -3.39, -3.36, -3.3,
          -3.24, -3.17, -3.11, -3.07, -3.04, -3.05, -3.1, -3.17, -3.28, -3.39, -3.52, -3.63, -3.74,
          -3.81, -3.86, -3.87, -3.84, -3.8, -3.74, -3.67, -3.61, -3.56, -3.52, -3.52, -3.55, -3.6,
          -3.68, -3.76, -3.85, -3.94, -4.01, -4.07, -4.1, -4.1, -4.08, -4.04, -3.99, -3.93, -3.88,
          -3.84, -3.82, -3.82, -3.85, -3.9, -3.97, -4.05, -4.14, -4.22, -4.3, -4.37, -4.41,
        ],
      },
      {
        label: 'Pistachio',
        tone: 'blue',
        amount: 617.22,
        values: [
          0.22, 0.25, 0.29, 0.33, 0.38, 0.44, 0.49, 0.53, 0.56, 0.58, 0.58, 0.57, 0.54, 0.51, 0.48,
          0.44, 0.42, 0.41, 0.42, 0.45, 0.5, 0.56, 0.63, 0.7, 0.77, 0.83, 0.88, 0.91, 0.92, 0.91,
          0.89, 0.86, 0.83, 0.8, 0.77, 0.76, 0.76, 0.78, 0.81, 0.85, 0.89, 0.94, 0.99, 1.03, 1.06,
          1.08, 1.08, 1.08, 1.06, 1.04, 1.02, 0.99, 0.97, 0.96, 0.96, 0.97, 0.99, 1.01, 1.03, 1.06,
          1.09, 1.11, 1.14, 1.15,
        ],
      },
    ],
    followUp: 'Should I rebalance flavors?',
  },
  {
    id: 'freezer',
    kind: 'spike',
    summary: [
      'Unusually high freezer bill on ',
      { strong: 'Dec 13' },
      ' — ',
      { figure: '+$1,834.66', sentiment: 'negative' },
      ' above your average.',
    ],
    title: 'High freezer spend',
    badge: 'Snapshot',
    tone: 'red',
    interval: 7,
    units: [
      { label: 'Spend', values: SPEND, format: money, threshold: '$2,112 threshold' },
      {
        label: 'Usage',
        values: SPEND.map((value) => value * KWH_PER_DOLLAR),
        format: (value) => `${Math.round(value)} kWh`,
        threshold: '82 kWh threshold',
      },
    ],
    total: '$2,112 spent',
    delta: '+$1,834.66',
    versus: 'vs 3 months',
    followUp: 'Get tips on cutting freezer costs',
  },
  {
    id: 'vanilla',
    kind: 'allocation',
    summary: [
      'You’re heavily invested in ',
      { mention: 'Vanilla', tone: 'orange' },
      ' — it’s ',
      { strong: '72.5%' },
      ' of your case.',
    ],
    title: 'Vanilla allocation',
    initial: 'V',
    tone: 'orange',
    segments: [
      { label: 'Vanilla', short: 'VAN', share: 72.5, value: 51785, tone: 'orange' },
      { label: 'Chocolate', short: 'CHOC', share: 22.8, value: 16278, tone: 'neutral' },
      { label: 'Mint', short: 'MINT', share: 4.7, value: 3357, tone: 'faint' },
    ],
    note: 'Contribution snapshot across current inventory value. Segment selection changes the inspected group without moving the card.',
    followUp: 'If we look at seasonals, what changes?',
  },
];
```

### insight-cards/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, glide
 * the pointer onto a control, press it and call the component's own handler, as a click would.
 * A sweep drags the pointer straight across a control at an even pace, reporting how far it has
 * gone each frame, to scrub a chart as a hand would.
 * 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.
 *
 * The pointer is an HTML box around a still drawing: it glides by an animation the compositor
 * plays, and the press scales a box that keeps a transform at rest, so nothing it does needs a
 * layout.
 */

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

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */
  moveTo: (target: Element | null | undefined, at?: Point) => Promise<void>;
  /** Presses and lets go; `action` runs as the pointer goes down. */
  press: (action: () => void) => Promise<void>;
  /** Glides onto `target` at `from`, then moves evenly to `to` over `ms`, calling `onShare`
   *  with the pointer's share of the target's width each frame. */
  sweep: (
    target: Element | null | undefined,
    from: Point,
    to: Point,
    ms: number,
    onShare: (share: number) => void,
  ) => Promise<void>;
};

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

/** Where the arrow's tip is, in its 22 × 22 box: the point the pointer acts on. */
const TIP: Point = { x: 3.3, y: 2.2 };

const place = ({ x, y }: Point) => `translate(${x - TIP.x}px, ${y - TIP.y}px)`;

/** 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;
  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.86, y: stage.offsetHeight * 0.96 };

  /** 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);
      }),
    );

  /** Glides to `to`, in the stage's own px. */
  const glide = (to: Point) => {
    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.16, y: (from.y + to.y) / 2 + dx * 0.16 };
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 12; i += 1) {
      const t = i / 12;
      const u = 1 - t;
      keyframes.push({
        transform: place({
          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,
        }),
      });
    }
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(keyframes, {
      duration: Math.min(950, Math.max(380, Math.hypot(dx, dy) * 1.6)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    return step(flight.finished);
  };

  const moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    const frame = stage.getBoundingClientRect();
    // Screen px to the stage's own, should the stage sit in a scaled container.
    const scale = frame.width / stage.offsetWidth || 1;
    const box = target.getBoundingClientRect();
    return glide({
      x: (box.left + box.width * share.x - frame.left) / scale,
      y: (box.top + box.height * share.y - frame.top) / scale,
    });
  };

  const press: Demo['press'] = async (action) => {
    // The glyph dips towards its tip.
    const tap = pointer.firstElementChild?.animate(
      [
        { transform: 'scale(1)' },
        { transform: 'scale(0.82)', offset: 0.35 },
        { transform: 'scale(1)' },
      ],
      { duration: 280, easing: 'ease-in-out' },
    );
    await wait(95);
    action();
    if (tap) await step(tap.finished);
  };

  const sweep: Demo['sweep'] = async (target, from, to, ms, onShare) => {
    if (!target) return;
    await moveTo(target, from);
    const frame = stage.getBoundingClientRect();
    const scale = frame.width / stage.offsetWidth || 1;
    const box = target.getBoundingClientRect();
    const point = (share: Point) => ({
      x: (box.left + box.width * share.x - frame.left) / scale,
      y: (box.top + box.height * share.y - frame.top) / scale,
    });
    const start = point(from);
    const end = point(to);
    at = end;
    flight?.cancel();
    pointer.style.transform = place(end);
    flight = pointer.animate([{ transform: place(start) }, { transform: place(end) }], {
      duration: ms,
      easing: 'linear',
    });
    const began = performance.now();
    let raf = 0;
    const tick = () => {
      const t = Math.min(1, (performance.now() - began) / ms);
      onShare(from.x + (to.x - from.x) * t);
      if (t < 1 && alive) raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    try {
      await step(flight.finished);
    } finally {
      cancelAnimationFrame(raf);
    }
  };

  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, moveTo, press, sweep }).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. */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 10,
  display: 'block',
  width: 22,
  height: 22,
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
  willChange: 'transform',
};

/** Inside it, what dips towards the tip when the pointer presses. */
const PRESS: CSSProperties = {
  display: 'block',
  width: 22,
  height: 22,
  transform: 'scale(1)',
  transformOrigin: `${TIP.x}px ${TIP.y}px`,
};

const DRAWING: CSSProperties = { display: 'block', width: 22, height: 22, overflow: 'visible' };
/** The arrow's colours, like its shadow's, are the stage's tokens (insight-cards.module.css). */
const ARROW: CSSProperties = { fill: 'var(--pointer)', stroke: 'var(--pointer-edge)' };

/** The arrow the demo moves around. It is decorative: the demo's effects show on the controls. */
export function DemoPointer({ ref }: { ref: Ref<HTMLSpanElement> }) {
  return (
    <span ref={ref} style={POINTER} aria-hidden="true">
      <span style={PRESS}>
        <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>
  );
}
```

### insight-cards/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 };
}
```