# Trace waterfall

> One agent run streaming live as a waterfall of spans: where its time and money go, which call failed and which was slowest.

A card that traces one agent run as it happens. The header names the goal and says Live, then Done; a strip of totals ticks up: duration, cost, tokens, model calls, tool calls and errors. Below, the spans stream in as a tree beside a timeline: agents as grey summary bars, model calls violet, tool calls teal, failures red, each bar followed by its time and cost, and the slowest call tagged. The axis rescales as the run grows. Fold an agent to see its calls as one multicoloured strip, and press any span for a card with its numbers, its input and its output.

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

Category: AI.

## Anatomy

1. **Status**: Live with a blue dot while the run streams, Done in green once its root span ends.
2. **Totals**: Duration, cost, tokens, model calls, tool calls and errors, ticking up as the run goes. Errors turn red above zero.
3. **Axis**: Ticks from 0s; the range eases to a round span a little longer than the run, so the ticks step as it grows.
4. **Agent row**: A chevron folds it; a tile with the model’s mark, the name in bold and the model, muted.
5. **Tile**: The model family’s mark or the tool’s glyph, on a small raised square.
6. **Bar**: Where a span sat in the run: violet for a model call, teal for a tool, red when it failed, grey for an agent.
7. **Label**: Its time and cost, after the bar, on the card’s colour so the grid stops behind it.
8. **Slowest**: The longest model or tool call that has finished, once two have.

## When to use

- One agent run, with the agents, model calls and tool calls under it, where the reader asks where the time and the money went.
- A run that is still going: the spans stream in, the bars grow and the totals tick up until it is done.
- Debugging a run that failed or got slow: the red span and the Slowest tag point at it, and its card says why.
- Runs of seconds to a few minutes, with up to thirty or so spans, two or three levels deep.
- A run page or a side panel at least 600 px wide; it narrows to a phone with shorter rows of names.

## When not to use

- Many runs side by side. Use a table of runs with their totals, and open one into this trace.
- A team of agents over the last hour, with handoffs. Use agent lanes on a shared clock.
- Runs of thousands of spans or hours long: the bars get too thin. Use a profiler with zoom and search.
- Only the answer matters to the reader. Show the result, with a link to the trace.
- A step-by-step progress view for the person waiting on the run. Use a checklist of steps.

## Installation

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

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

Or add the registry to components.json once, as a namespace that coding agents and the shadcn MCP server search by name:

```json
{
  "registries": {
    "@brunodelorence": "https://brunodelorence.com/r/{name}.json"
  }
}
```

```sh
npx shadcn@latest add @brunodelorence/trace-waterfall
```

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
'use client';

import { useEffect, useState } from 'react';
import { TraceWaterfall, type Span } from './trace-waterfall';

export function RunTrace({ spans, startedAt }: { spans: Span[]; startedAt: number }) {
  // Seconds since the run started, while it streams.
  const [now, setNow] = useState(0);
  useEffect(() => {
    const timer = setInterval(() => setNow((Date.now() - startedAt) / 1000), 250);
    return () => clearInterval(timer);
  }, [startedAt]);

  return (
    <TraceWaterfall
      spans={spans}
      now={now}
      goal="Answer the support queue"
      onOpenSpan={(span) => console.log('open', span.id)}
      autoplay={false}
    />
  );
}
```

## States

- **Streaming**: Forty seconds in: running spans fade in from their start with a spinner by their name, and the blue now line leads.
- **Done**: The run ended: a green Done, no now line, the axis settled at 1m 30s, and one error in red.
- **Agents folded**: Researcher and Writer folded: each shows +N and its spans as one strip of colour inside its bar.
- **Run folded**: The root folded while it streams: the whole run in one strip that grows.
- **Span card**: Pressed or Enter on a span: its kind, status, numbers, input and output, anchored under its bar.
- **Failed span**: A red bar with a warning by its name; its card says what went wrong.
- **Just started**: Three seconds in: the root and its first model call, the axis at 0 to 5 seconds.

## How it works

- The run has a clock in seconds. Every frame while it streams, it writes two custom properties on the waterfall, --t (where the run is) and --range (the seconds the axis shows); each row carries its span’s --s and --e, and a bar sits at s / range of the timeline and is (min(t, e) − s) / range wide. Bars grow and the axis rescales in CSS, with no React render per frame.
- React catches up about twelve times a second, for what the clock means in words and numbers: which spans have started, the totals, each bar’s label, the Live or Done pill and the slowest call.
- The axis eases towards a round range a little longer than the run so far (15s, 30s, 45s, 1m, 1m 30s…), with an exponential step each frame, so the bars slide left together as the ticks change; a new tick fades in.
- Rows are grid items whose single track goes from 0fr to 1fr: a row opens from nothing when its span starts, and folding an agent closes its rows the same way, inert while hidden. A folded agent draws its calls as thin translucent segments at their own times inside its bar, so overlapping calls show darker.
- Numbers that tick up are drawn a character at a time, keyed by position and value, so only a digit that changed fades up into place.
- The span card is placed under its row (over it when the stage’s bottom is too near) and follows its bar along the timeline in CSS, clamped inside the card, as the axis rescales.
- The demo replays the run six times faster than real time, opens a few spans and folds agents as they finish; the second pass folds the whole run into one strip. Any input of the reader’s stops it, and a run in progress carries on to its end.

## API reference

- **spans** (`readonly Span[]`, default `SPANS`): The run’s spans: an id, its parent, a kind (agent, llm or tool), a name, a muted detail (the model or the tool), an icon, its start and end in seconds from the start of the run (no end while it runs), and for calls a cost and tokens in and out; an error makes it fail, and an input and output show in its card.
- **goal** (`string`, default `'Launch the new pricing page by Friday'`): What the run was asked to do, muted beside the title.
- **title** (`string`, default `'Trace'`): The header’s title.
- **now** (`number`): Seconds since the run started, for a run you stream; it also turns the demo off. Without it, the trace plays its own clock: from the start while the demo plays, or resting at the end.
- **speed** (`number`, default `6`): How fast the trace’s own clock replays the run: seconds of run per second.
- **defaultFolded** (`readonly string[]`): Agents (their ids) folded at first.
- **defaultOpenSpan** (`string`): Opens with the card of this span showing (its id), for a static view; turns the demo off.
- **onOpenSpan** (`(span: Span) => void`): Called when a span’s card opens.
- **onFoldChange** (`(span: Span, folded: boolean) => void`): Called when an agent is folded or unfolded.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer replays the run, opens spans and folds agents; any input hands it to the reader.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark colours, or the reader’s; without it, the page’s color-scheme.
- **className** (`string`): Classes for the stage.

## Accessibility

- The card is a section named by its title; the totals are a description list, and the spans a list whose rows are buttons named by the span’s name, kind, status, time and cost.
- Each agent’s chevron is a button with aria-expanded, named Fold or Unfold and the agent. Folded rows are hidden from assistive technology and inert, so the focus skips them.
- Pressing a span, or Enter on it, opens its card: a non-modal dialog named by the span, which takes the focus when the reader opened it. Escape or the close button closes it and gives the focus back to the span; a press outside closes it too.
- A failure and the end of the run are announced in a polite live region; statuses are words (Live, Done, Running, Failed), never colour alone.
- Every control has a visible focus ring. Under reduced motion, the run jumps to its end, and rows, digits, the sweep on running agents and the card appear without moving; the demo does not play.
- The demo pointer is decorative and hidden from assistive technology.

## Source

### trace-waterfall/trace-waterfall.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type Ref,
} from 'react';
import { GOAL, SPANS, type Span } from './data';
import { DemoPointer, runDemo, type Point } from './demo-pointer';
import { duration, money, spent, tick, tokens } from './format';
import { Chevron, Cross, Sparkle, SpanGlyph, Warning } from './glyphs';
import { useAutoplay } from './use-autoplay';
import styles from './trace-waterfall.module.css';

/*
 * Trace waterfall: one agent run, streaming live, showing where its time and money go. A header
 * with the run's goal and a Live or Done pill, a strip of totals that tick up, then the spans:
 * the tree on the left (fold an agent to see its spans as one multicoloured strip), a timeline on
 * the right with a bar per span, its time and its cost after it, and the slowest one tagged.
 *
 * - The run has a clock in seconds. Every frame while it streams, the clock writes two custom
 *   properties on the waterfall, --t (where the run is) and --range (the seconds the axis shows);
 *   each bar is placed and sized from them and its own --s and --e in CSS, so bars grow and the
 *   axis rescales without React. React catches up about twelve times a second, for the numbers.
 * - The axis range eases towards a round span a little longer than the run so far (15s, 30s,
 *   45s, 1m, 1m 30s…), so the ticks step and the bars slide together as the run grows.
 * - Press a span, or focus it and press Enter, for its card: what it is, its status, its time,
 *   cost and tokens, what it was given and what it gave back. Escape closes it.
 * - While on screen and untouched, a demo pointer opens a few cards and folds agents as the run
 *   streams six times faster than real time, then replays it. Any input hands it to the reader;
 *   a run in progress then carries on to its end.
 */

export type { Span, SpanIcon, SpanKind } from './data';

/** How often React catches up with the clock while the run streams: in ms. */
const COMMIT_MS = 80;
/** The spans the axis can show, in seconds: it eases to the first a little longer than the run. */
const RANGES = [5, 10, 15, 20, 30, 45, 60, 90, 120, 180, 240, 300, 450, 600, 900, 1200, 1800];
/** The axis's steps: the first that makes fewer than 4.5 ticks across the range. */
const STEPS = [1, 2, 3, 5, 10, 15, 20, 30, 60, 120, 300, 600];
/** A span without an end yet runs on: its end, for the stylesheet. */
const OPEN_END = 1e6;

function targetRange(t: number) {
  const want = Math.max(t, 1) * 1.12;
  return RANGES.find((range) => range >= want) ?? Math.ceil(want / 600) * 600;
}

function tickStep(range: number) {
  return STEPS.find((step) => range / step < 4.5) ?? 1800;
}

/** What a span is, in words: the root agent, an agent under it, or a call. */
const kindWord = (span: Span, depth: number) =>
  span.kind === 'agent'
    ? depth === 0
      ? 'Agent'
      : 'Sub-agent'
    : span.kind === 'llm'
      ? 'LLM call'
      : 'Tool call';
const STATUS_WORD = { running: 'Running', done: 'Done', failed: 'Failed' } as const;

type Status = keyof typeof STATUS_WORD;

type TreeNode = {
  span: Span;
  depth: number;
  /** Every span under it, in the tree's order. */
  descendants: Span[];
  /** The model and tool calls under it: what its strip and its sums are made of. */
  leaves: Span[];
};

/** The spans as a tree, in the order they are drawn: each parent before its children by start. */
function buildTree(spans: readonly Span[]) {
  const children = new Map<string | undefined, Span[]>();
  for (const span of spans) {
    const parent = span.parent && spans.some((each) => each.id === span.parent) ? span.parent : '';
    const list = children.get(parent) ?? [];
    list.push(span);
    children.set(parent, list);
  }
  for (const list of children.values()) list.sort((a, b) => a.start - b.start);
  const order: TreeNode[] = [];
  const nodes = new Map<string, TreeNode>();
  const walk = (span: Span, depth: number): TreeNode => {
    const node: TreeNode = { span, depth, descendants: [], leaves: [] };
    nodes.set(span.id, node);
    order.push(node);
    if (span.kind !== 'agent') node.leaves.push(span);
    for (const child of children.get(span.id) ?? []) {
      const sub = walk(child, depth + 1);
      node.descendants.push(child, ...sub.descendants);
      node.leaves.push(...sub.leaves);
    }
    return node;
  };
  for (const root of children.get('') ?? []) walk(root, 0);
  const ends = spans.map((span) => span.end ?? Infinity);
  return { order, nodes, end: Math.max(0, ...ends) };
}

const endOf = (span: Span) => span.end ?? Infinity;
const progress = (span: Span, t: number) =>
  Math.max(0, Math.min(1, (t - span.start) / Math.max(1e-6, endOf(span) - span.start)));
const statusOf = (span: Span, t: number): Status =>
  t < endOf(span) ? 'running' : span.error ? 'failed' : 'done';

/** A number whose changed digits fade in, so a value ticking up reads as change, not flicker. */
function Ticker({ text }: { text: string }) {
  const chars = Array.from(text);
  return (
    <>
      {chars.map((char, i) => (
        <span key={`${chars.length - i}:${char}`} className={/\d/.test(char) ? styles.digit : ''}>
          {char}
        </span>
      ))}
    </>
  );
}

type Open = { id: string; via: 'reader' | 'demo' };

export function TraceWaterfall({
  spans = SPANS,
  goal = GOAL,
  title = 'Trace',
  now,
  speed = 6,
  defaultFolded,
  defaultOpenSpan,
  onOpenSpan,
  onFoldChange,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The run's spans: agents, model calls and tool calls, each with its parent and its times. */
  spans?: readonly Span[];
  /** What the run was asked to do, muted beside the title. */
  goal?: string;
  /** The header's title. */
  title?: string;
  /** Seconds since the run started, for a run you stream; without it, the trace plays its own. */
  now?: number;
  /** How fast the trace's own clock plays the run: seconds of run per second. */
  speed?: number;
  /** Agents (their ids) shown folded at first. */
  defaultFolded?: readonly string[];
  /** Opens with the card of this span showing (its id); turns the demo off. */
  defaultOpenSpan?: string;
  /** Called when a span's card opens. */
  onOpenSpan?: (span: Span) => void;
  /** Called when an agent is folded or unfolded. */
  onFoldChange?: (span: Span, folded: boolean) => void;
  /** While on screen and untouched, a demo pointer replays the run and opens a few spans. */
  autoplay?: boolean;
  /** 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, visible, take } = useAutoplay<HTMLDivElement>(6000);
  const controlled = now !== undefined;
  const demo = autoplay && playing && !controlled && !defaultOpenSpan;
  const uid = useId();

  const tree = useMemo(() => buildTree(spans), [spans]);
  const runEnd = tree.end;

  // The clock. `live` is where it really is; `t` is what React last drew.
  const [ownT, setOwnT] = useState(() =>
    autoplay && !defaultOpenSpan && Number.isFinite(runEnd) ? 0 : runEnd,
  );
  const t = Math.max(0, controlled ? now : Number.isFinite(ownT) ? ownT : 0);
  const live = useRef({ t, range: targetRange(t), streaming: false, visible: false, speed });
  const waterfall = useRef<HTMLDivElement>(null);
  const frame = useRef(0);

  const [folded, setFolded] = useState<ReadonlySet<string>>(() => new Set(defaultFolded));
  const [open, setOpen] = useState<Open | null>(() =>
    defaultOpenSpan ? { id: defaultOpenSpan, via: 'reader' } : null,
  );
  const [demoHover, setDemoHover] = useState<string | null>(null);
  const [demoPress, setDemoPress] = useState<string | null>(null);
  const [fading, setFading] = useState(false);
  const [message, setMessage] = useState('');

  const rows = useRef(new Map<string, HTMLLIElement>());
  const bars = useRef(new Map<string, HTMLElement>());
  const hits = useRef(new Map<string, HTMLButtonElement>());
  const chevrons = useRef(new Map<string, HTMLButtonElement>());
  const pop = useRef<HTMLDivElement>(null);
  const closeButton = useRef<HTMLButtonElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const card = useRef<HTMLElement>(null);
  /** The reader opened the card: it takes the focus, and gives it back. */
  const popFocus = useRef(false);

  const paint = useCallback(() => {
    const box = waterfall.current;
    if (!box) return;
    box.style.setProperty('--t', live.current.t.toFixed(3));
    box.style.setProperty('--range', live.current.range.toFixed(4));
  }, []);

  /** Runs the clock's frames until the run stops streaming and the axis settles. */
  const kick = useCallback(() => {
    if (frame.current) return;
    let last = performance.now();
    let committed = 0;
    const step = (time: number) => {
      const clock = live.current;
      const dt = Math.min(100, time - last);
      last = time;
      const moving = clock.streaming && clock.visible;
      if (moving) {
        clock.t = Math.min(runEnd, clock.t + (dt / 1000) * clock.speed);
        if (clock.t >= runEnd) clock.streaming = false;
      }
      const target = targetRange(clock.t);
      clock.range += (target - clock.range) * (1 - Math.exp(-dt / 150));
      if (Math.abs(target - clock.range) < target * 0.002) clock.range = target;
      paint();
      const settled = !(clock.streaming && clock.visible) && clock.range === target;
      if (time - committed >= COMMIT_MS || settled) {
        committed = time;
        setOwnT(clock.t);
      }
      frame.current = settled ? 0 : requestAnimationFrame(step);
    };
    frame.current = requestAnimationFrame(step);
  }, [paint, runEnd]);
  useEffect(
    () => () => {
      cancelAnimationFrame(frame.current);
      frame.current = 0;
    },
    [],
  );

  useLayoutEffect(() => {
    live.current.speed = speed;
    paint();
  }, [paint, speed]);

  // On and off screen: the run pauses while out of sight.
  useEffect(() => {
    live.current.visible = visible;
    if (visible) kick();
  }, [visible, kick]);

  // A run you stream: the clock follows `now`, and the axis eases after it.
  useEffect(() => {
    if (!controlled) return;
    live.current.t = Math.max(0, now);
    kick();
  }, [controlled, now, kick]);

  // When the demo is not playing, a run in progress carries on to its end (or jumps there, under
  // reduced motion).
  useEffect(() => {
    if (controlled || demo) return;
    const clock = live.current;
    if (clock.t >= runEnd || !Number.isFinite(runEnd)) return;
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches || !autoplay) {
      clock.t = runEnd;
      clock.range = targetRange(runEnd);
      clock.streaming = false;
      paint();
      // The run jumps to its end: drawn at once, with nothing to animate.
      // eslint-disable-next-line react-hooks/set-state-in-effect
      setOwnT(runEnd);
    } else {
      clock.streaming = true;
      kick();
    }
  }, [controlled, demo, runEnd, autoplay, kick, paint]);

  const announce = (text: string) => setMessage((was) => (was === text ? `${text} ` : text));

  // ---- What the clock means ------------------------------------------------------------------

  const done = Number.isFinite(runEnd) && t >= runEnd;
  const started = (span: Span) => span.start <= t;
  const nodeOf = (id: string) => tree.nodes.get(id);

  const sums = (node: TreeNode) => {
    let cost = 0;
    let read = 0;
    let wrote = 0;
    for (const leaf of node.leaves) {
      const share = progress(leaf, t);
      cost += (leaf.cost ?? 0) * share;
      read += (leaf.tokensIn ?? 0) * share;
      wrote += (leaf.tokensOut ?? 0) * share;
    }
    return { cost, read, wrote };
  };

  const stats = useMemo(() => {
    let cost = 0;
    let count = 0;
    let llm = 0;
    let tool = 0;
    let errors = 0;
    for (const span of spans) {
      if (span.start > t) continue;
      if (span.kind === 'llm') llm += 1;
      if (span.kind === 'tool') tool += 1;
      if (span.error && t >= endOf(span)) errors += 1;
      if (span.kind === 'agent') continue;
      const share = progress(span, t);
      cost += (span.cost ?? 0) * share;
      count += ((span.tokensIn ?? 0) + (span.tokensOut ?? 0)) * share;
    }
    return [
      { id: 'duration', label: 'Duration', value: duration(t) },
      { id: 'cost', label: 'Cost', value: money(cost) || '$0.00' },
      { id: 'tokens', label: 'Tokens', value: tokens(count) },
      { id: 'llm', label: 'LLM calls', value: String(llm) },
      { id: 'tools', label: 'Tool calls', value: String(tool) },
      { id: 'errors', label: 'Errors', value: String(errors), alert: errors > 0 },
    ];
  }, [spans, t]);

  /** The slowest model or tool call that has finished, once two have. */
  const slowest = useMemo(() => {
    const finished = spans.filter((span) => span.kind !== 'agent' && endOf(span) <= t);
    if (finished.length < 2) return null;
    return finished.reduce((a, b) => (b.end! - b.start > a.end! - a.start ? b : a)).id;
  }, [spans, t]);

  /** Under a folded agent: hidden. */
  const hiddenIds = useMemo(() => {
    const hidden = new Set<string>();
    for (const id of folded) {
      for (const span of tree.nodes.get(id)?.descendants ?? []) hidden.add(span.id);
    }
    return hidden;
  }, [folded, tree]);

  // New spans from the props: start over (set while rendering, so it never shows the old run).
  const givenKey = JSON.stringify(spans);
  const [given, setGiven] = useState(givenKey);
  if (given !== givenKey) {
    setGiven(givenKey);
    setOpen(null);
  }

  // ---- Folding and the span card -------------------------------------------------------------

  const toggleFold = (id: string) => {
    const span = nodeOf(id)?.span;
    if (!span) return;
    const willFold = !folded.has(id);
    setFolded((all) => {
      const next = new Set(all);
      if (willFold) next.add(id);
      else next.delete(id);
      return next;
    });
    if (willFold && open && nodeOf(id)?.descendants.some((each) => each.id === open.id))
      setOpen(null);
    onFoldChange?.(span, willFold);
  };

  const openSpan = (id: string, via: Open['via']) => {
    const span = nodeOf(id)?.span;
    if (!span) return;
    popFocus.current = via === 'reader';
    setOpen({ id, via });
    onOpenSpan?.(span);
  };

  const close = (giveFocus: boolean) => {
    const id = open?.id;
    setOpen(null);
    if (giveFocus && id) hits.current.get(id)?.focus();
  };

  // The card: under its span's row, or over it when the stage's bottom is too near. Its left
  // follows the bar in CSS, as the axis rescales.
  const place = useCallback(() => {
    const box = pop.current;
    const stage = root.current;
    const row = open ? rows.current.get(open.id) : undefined;
    const holder = waterfall.current;
    if (!box || !stage || !row || !holder) return;
    const scale = stage.getBoundingClientRect().width / stage.offsetWidth || 1;
    const h = holder.getBoundingClientRect();
    const r = row.getBoundingClientRect();
    const s = stage.getBoundingClientRect();
    const top = (r.top - h.top) / scale;
    const bottom = (r.bottom - h.top) / scale;
    const holderInStage = (h.top - s.top) / scale;
    const height = box.offsetHeight;
    const fitsBelow = holderInStage + bottom + 6 + height <= stage.offsetHeight - 8;
    const fitsAbove = holderInStage + top - 6 - height >= 8;
    box.style.top = `${!fitsBelow && fitsAbove ? top - 6 - height : bottom + 6}px`;
  }, [open, root]);
  useLayoutEffect(place);

  // The card: the reader's focus goes in; a press outside closes it.
  useEffect(() => {
    if (!open) return;
    if (popFocus.current) pop.current?.focus();
    const away = (event: PointerEvent) => {
      const target = event.target as Node;
      if (pop.current?.contains(target) || hits.current.get(open.id)?.contains(target)) return;
      setOpen(null);
    };
    document.addEventListener('pointerdown', away);
    return () => document.removeEventListener('pointerdown', away);
  }, [open]);

  // Things worth saying out loud: a failure, and the end of the run.
  const failedCount = Number(stats[5].value);
  const lastFailed = useRef(failedCount);
  const lastDone = useRef(done);
  useEffect(() => {
    if (failedCount > lastFailed.current) {
      const failed = spans.filter((span) => span.error && endOf(span) <= t).at(-1);
      // eslint-disable-next-line react-hooks/set-state-in-effect
      if (failed) announce(`${failed.name} failed.`);
    }
    if (done && !lastDone.current) {
      announce(
        `Run done: ${stats[0].value}, ${stats[1].value}, ${failedCount} error${failedCount === 1 ? '' : 's'}.`,
      );
    }
    lastFailed.current = failedCount;
    lastDone.current = done;
  }, [failedCount, done, spans, t, stats]);

  // ---- The demo ----------------------------------------------------------------------------

  const demoStep = useEffectEvent((step: DemoStep) => {
    switch (step.kind) {
      case 'hover':
        setDemoHover(step.id);
        break;
      case 'press':
        setDemoPress(step.id);
        break;
      case 'fold':
        setDemoPress(null);
        toggleFold(step.id);
        break;
      case 'open':
        setDemoHover(step.id);
        openSpan(step.id, 'demo');
        break;
      case 'close':
        setDemoPress(null);
        setOpen((was) => (was?.via === 'demo' ? null : was));
        break;
      case 'fade':
        setFading(step.on);
        break;
      case 'reset': {
        const clock = live.current;
        clock.t = 0;
        clock.range = targetRange(0);
        clock.streaming = true;
        paint();
        setOwnT(0);
        setFolded(new Set());
        setOpen(null);
        setDemoHover(null);
        setDemoPress(null);
        kick();
        break;
      }
    }
  });

  /** Whether the trace has moved on from where the demo starts. */
  const dirty = useEffectEvent(() => live.current.t > 0.4 || folded.size > 0 || open !== null);

  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!demo || !stage || !arrow) return;

    /** A point on an element, in the stage's px: `fx`, `fy` are shares of its box. */
    const on = (element: Element | null | undefined, fx = 0.5, fy = 0.5): Point => {
      const frameBox = stage.getBoundingClientRect();
      const scale = frameBox.width / stage.offsetWidth || 1;
      const rect = element?.getBoundingClientRect() ?? frameBox;
      return {
        x: (rect.left + rect.width * fx - frameBox.left) / scale,
        y: (rect.top + rect.height * fy - frameBox.top) / scale,
      };
    };
    /** A point on a span's bar, kept inside the timeline (a bar can run past its right edge). */
    const onBar = (id: string, share = 0.5) => {
      const bar = bars.current.get(id);
      const track = bar?.parentElement;
      if (!bar || !track) return on(rows.current.get(id), 0.6);
      const b = bar.getBoundingClientRect();
      const k = track.getBoundingClientRect();
      const right = Math.min(b.right, k.right - 12);
      const left = Math.min(b.left, right);
      const x = left + Math.max(4, (right - left) * share);
      return on(bar, (x - b.left) / (b.width || 1), 0.55);
    };

    const stop = runDemo(stage, arrow, async ({ wait, glide, glyph }) => {
      /** Until the run's clock reaches `at` seconds. */
      const until = (at: number) =>
        wait(Math.max(0, ((at - live.current.t) / live.current.speed) * 1000));
      const hover = async (id: string, share: number, ms: number) => {
        glyph('arrow');
        await glide(onBar(id, share), ms);
        glyph('hand');
        demoStep({ kind: 'hover', id });
      };
      const fold = async (id: string, ms = 620) => {
        demoStep({ kind: 'hover', id: null });
        glyph('arrow');
        await glide(on(chevrons.current.get(id), 0.5, 0.55), ms);
        glyph('hand');
        demoStep({ kind: 'press', id });
        await wait(180);
        demoStep({ kind: 'fold', id });
        await wait(260);
      };
      const look = async (id: string, share: number, hold: number) => {
        await hover(id, share, 640);
        await wait(240);
        demoStep({ kind: 'open', id });
        await wait(hold);
        glyph('arrow');
        await glide(on(closeButton.current, 0.5, 0.5), 520);
        glyph('hand');
        demoStep({ kind: 'press', id: 'close' });
        await wait(160);
        demoStep({ kind: 'close' });
        demoStep({ kind: 'hover', id: null });
        await wait(200);
      };
      const startOver = async () => {
        if (dirty()) {
          demoStep({ kind: 'fade', on: true });
          await wait(320);
          demoStep({ kind: 'reset' });
          await wait(60);
          demoStep({ kind: 'fade', on: false });
        } else demoStep({ kind: 'reset' });
      };

      for (;;) {
        // The run streams; the pointer looks at spans, then folds agents as they finish.
        await startOver();
        await until(3.6);
        await hover('plan', 0.55, 760);
        await until(12.5);
        await hover('search-b', 0.6, 620);
        await until(29.8);
        await look('reviews', 0.5, 1500);
        await until(38.6);
        await fold('researcher');
        await until(48.8);
        await fold('writer');
        await until(51.4);
        await look('tests', 0.5, 1700);
        await hover('fix', 0.5, 600);
        await until(runEnd);
        await wait(500);
        await fold('writer');
        await wait(300);
        await look('draft', 0.55, 2200);
        glyph('arrow');
        await glide(on(card.current, 0.86, 1.04), 700);
        await wait(900);

        // Again, with the whole run folded into one strip that grows.
        await startOver();
        await until(2.6);
        await fold('orchestrator', 700);
        await hover('orchestrator', 0.7, 560);
        await until(33);
        await fold('orchestrator', 520);
        await until(40);
        await fold('researcher', 560);
        await until(49);
        await fold('writer', 520);
        await until(runEnd);
        await wait(400);
        await look('coder', 0.4, 2000);
        glyph('arrow');
        await glide(on(card.current, 0.86, 1.04), 700);
        await wait(900);
      }
    });
    return () => {
      stop();
      setDemoHover(null);
      setDemoPress(null);
      setFading(false);
      setOpen((was) => (was?.via === 'demo' ? null : was));
    };
  }, [demo, root, runEnd]);

  // ---- Drawing -----------------------------------------------------------------------------

  const range = targetRange(t);
  const step = tickStep(range);
  const ticks: number[] = [];
  for (let at = 0; at <= range * 1.25; at += step) ticks.push(at);

  const openNode = open ? nodeOf(open.id) : undefined;
  const shown = openNode && started(openNode.span) && !hiddenIds.has(openNode.span.id);

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="stage"
      data-color-scheme={colorScheme}
      onPointerDownCapture={take}
      onKeyDownCapture={(event) => {
        take();
        if (event.key === 'Escape' && open) close(true);
      }}
      onPointerMove={(event) => {
        // The reader's own pointer on the trace takes over from the demo's.
        if (event.pointerType !== 'touch') take();
      }}
    >
      <section ref={card} className={styles.card} data-part="card" aria-labelledby={`${uid}-title`}>
        <header className={styles.header} data-part="header">
          <Sparkle className={styles.sparkle} />
          <h2 id={`${uid}-title`} className={styles.title}>
            {title}
          </h2>
          <p className={styles.goal}>{goal}</p>
          <span
            key={done ? 'done' : 'live'}
            className={styles.status}
            data-part="status"
            data-tone={done ? 'done' : 'live'}
          >
            {!done && <span className={styles.statusDot} aria-hidden="true" />}
            {done ? 'Done' : 'Live'}
          </span>
        </header>

        <dl className={styles.stats} data-part="stats">
          {stats.map((stat) => (
            <div key={stat.id} className={styles.stat} data-alert={stat.alert || undefined}>
              <dt className={styles.statLabel}>{stat.label}</dt>
              <dd className={styles.statValue}>
                <Ticker text={stat.value} />
              </dd>
            </div>
          ))}
        </dl>

        <div
          ref={waterfall}
          className={styles.waterfall}
          data-part="waterfall"
          data-fading={fading || undefined}
          data-live={!done || undefined}
          onTransitionEnd={place}
        >
          <div className={styles.grid} aria-hidden="true">
            {ticks.map((at) => (
              <span key={at} className={styles.gridLine} style={{ '--v': at } as CSSProperties} />
            ))}
            <span className={styles.now} data-part="now" />
          </div>

          <div className={styles.axis} aria-hidden="true" data-part="axis">
            <span className={styles.axisLabel}>Span</span>
            <span className={styles.ticks}>
              {ticks.map((at) => (
                <span key={at} className={styles.tick} style={{ '--v': at } as CSSProperties}>
                  {tick(at)}
                </span>
              ))}
            </span>
          </div>

          <ul className={styles.rows} aria-label="Spans of the run">
            {tree.order.map((node) => {
              const { span, depth } = node;
              if (!started(span)) return null;
              const hidden = hiddenIds.has(span.id);
              const status = statusOf(span, t);
              const isAgent = span.kind === 'agent';
              const isFolded = isAgent && folded.has(span.id);
              const under = node.descendants.filter(started);
              const elapsed = Math.min(t, endOf(span)) - span.start;
              const cost = isAgent ? sums(node).cost : (span.cost ?? 0) * progress(span, t);
              const kind = kindWord(span, depth);
              const isOpen = shown && open?.id === span.id;
              return (
                <li
                  key={span.id}
                  ref={(element) => {
                    if (element) rows.current.set(span.id, element);
                    else rows.current.delete(span.id);
                  }}
                  className={styles.row}
                  data-part="row"
                  data-kind={span.kind}
                  data-status={status}
                  data-folded={isFolded || undefined}
                  data-hidden={hidden || undefined}
                  data-open={isOpen || undefined}
                  data-hover={demoHover === span.id || undefined}
                  aria-hidden={hidden || undefined}
                  inert={hidden}
                  style={
                    {
                      '--s': span.start,
                      '--e': Number.isFinite(endOf(span)) ? endOf(span) : OPEN_END,
                      '--depth': depth,
                    } as CSSProperties
                  }
                >
                  <div className={styles.rowInner}>
                    <button
                      type="button"
                      ref={(element) => {
                        if (element) hits.current.set(span.id, element);
                        else hits.current.delete(span.id);
                      }}
                      className={styles.hit}
                      data-part="span"
                      aria-haspopup="dialog"
                      aria-expanded={isOpen || false}
                      aria-label={`${span.name}, ${kind}, ${STATUS_WORD[status].toLowerCase()}, ${spent(elapsed, cost)}`}
                      onClick={() => (isOpen ? close(false) : openSpan(span.id, 'reader'))}
                    />
                    <div className={styles.cell}>
                      {Array.from({ length: depth }, (_, level) => (
                        <span
                          key={level}
                          className={styles.guide}
                          style={{ '--level': level } as CSSProperties}
                          aria-hidden="true"
                        />
                      ))}
                      {isAgent && node.descendants.length > 0 && (
                        <button
                          type="button"
                          ref={(element) => {
                            if (element) chevrons.current.set(span.id, element);
                            else chevrons.current.delete(span.id);
                          }}
                          className={styles.chevron}
                          data-part="chevron"
                          data-pressed={demoPress === span.id || undefined}
                          aria-expanded={!isFolded}
                          aria-label={`${isFolded ? 'Unfold' : 'Fold'} ${span.name}`}
                          onClick={() => toggleFold(span.id)}
                        >
                          <Chevron className={styles.chevronGlyph} />
                        </button>
                      )}
                      <span className={styles.tile} data-part="tile" aria-hidden="true">
                        <SpanGlyph icon={span.icon} className={styles.glyph} />
                      </span>
                      <span className={styles.name} aria-hidden="true">
                        {span.name}
                      </span>
                      <span className={styles.detail} aria-hidden="true">
                        {span.detail}
                      </span>
                      {isFolded && under.length > 0 && (
                        <span className={styles.more} data-part="more" aria-hidden="true">
                          +{under.length}
                        </span>
                      )}
                      {status === 'running' && !isAgent && (
                        <span className={styles.spinner} data-part="spinner" aria-hidden="true" />
                      )}
                      {status === 'failed' && <Warning className={styles.warning} />}
                    </div>
                    <div className={styles.track} aria-hidden="true">
                      <span
                        ref={(element) => {
                          if (element) bars.current.set(span.id, element);
                          else bars.current.delete(span.id);
                        }}
                        className={styles.bar}
                        data-part="bar"
                      />
                      {isFolded &&
                        node.leaves.filter(started).map((leaf) => (
                          <span
                            key={leaf.id}
                            className={styles.segment}
                            data-part="segment"
                            data-kind={leaf.kind}
                            data-status={statusOf(leaf, t)}
                            style={
                              {
                                '--s': leaf.start,
                                '--e': Number.isFinite(endOf(leaf)) ? endOf(leaf) : OPEN_END,
                              } as CSSProperties
                            }
                          />
                        ))}
                      <span className={styles.after}>
                        <span className={styles.label} data-part="label">
                          <Ticker text={spent(elapsed, cost)} />
                        </span>
                        {slowest === span.id && (
                          <span className={styles.slowest} data-part="slowest">
                            Slowest
                          </span>
                        )}
                      </span>
                    </div>
                  </div>
                </li>
              );
            })}
          </ul>

          {shown && openNode && (
            <SpanCard
              node={openNode}
              t={t}
              uid={uid}
              sums={sums(openNode)}
              under={openNode.descendants.filter(started).length}
              refBox={pop}
              closeRef={closeButton}
              closePressed={demoPress === 'close'}
              onClose={() => close(true)}
            />
          )}
        </div>
      </section>

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

/** The card a span opens: what it is, where it stands, its numbers, its input and output. */
function SpanCard({
  node,
  t,
  uid,
  sums,
  under,
  refBox,
  closeRef,
  closePressed,
  onClose,
}: {
  node: TreeNode;
  t: number;
  uid: string;
  sums: { cost: number; read: number; wrote: number };
  under: number;
  refBox: Ref<HTMLDivElement>;
  closeRef: Ref<HTMLButtonElement>;
  closePressed: boolean;
  onClose: () => void;
}) {
  const { span, depth } = node;
  const status = statusOf(span, t);
  const isAgent = span.kind === 'agent';
  const kind = kindWord(span, depth);
  const facts = [
    { label: 'Duration', value: duration(Math.min(t, endOf(span)) - span.start) },
    { label: 'Started', value: `+${duration(span.start)}` },
    { label: 'Cost', value: money(sums.cost) },
    { label: 'Tokens in', value: sums.read > 0 ? tokens(sums.read) : '' },
    { label: 'Tokens out', value: sums.wrote > 0 ? tokens(sums.wrote) : '' },
    { label: 'Spans', value: isAgent ? String(under) : '' },
  ].filter((fact) => fact.value);
  return (
    <div
      ref={refBox}
      role="dialog"
      aria-labelledby={`${uid}-card`}
      tabIndex={-1}
      className={styles.pop}
      data-part="popover"
      style={{ '--ps': span.start } as CSSProperties}
    >
      <div className={styles.popHead}>
        <span className={styles.popTile} aria-hidden="true">
          <SpanGlyph icon={span.icon} className={styles.popGlyph} />
        </span>
        <div className={styles.popTitles}>
          <h3 id={`${uid}-card`} className={styles.popName}>
            {span.name}
          </h3>
          <p className={styles.popKind}>
            {kind} · {span.detail}
          </p>
        </div>
        <span className={styles.pill} data-part="pill" data-tone={status}>
          {STATUS_WORD[status]}
        </span>
        <button
          ref={closeRef}
          type="button"
          className={styles.close}
          data-pressed={closePressed || undefined}
          aria-label={`Close ${span.name}`}
          onClick={onClose}
        >
          <Cross className={styles.closeGlyph} />
        </button>
      </div>
      <dl className={styles.facts} data-part="facts">
        {facts.map((fact) => (
          <div key={fact.label} className={styles.fact}>
            <dt className={styles.factLabel}>{fact.label}</dt>
            <dd className={styles.factValue}>
              <Ticker text={fact.value} />
            </dd>
          </div>
        ))}
      </dl>
      {status === 'failed' && span.error && (
        <p className={styles.error} data-part="error">
          <Warning className={styles.errorGlyph} />
          <span>{span.error}</span>
        </p>
      )}
      {span.input && (
        <div className={styles.io}>
          <h4 className={styles.ioLabel}>Input</h4>
          <p className={styles.ioText}>{span.input}</p>
        </div>
      )}
      {status === 'done' && span.output && (
        <div className={styles.io}>
          <h4 className={styles.ioLabel}>Output</h4>
          <p className={styles.ioText}>{span.output}</p>
        </div>
      )}
    </div>
  );
}

type DemoStep =
  | { kind: 'hover'; id: string | null }
  | { kind: 'press'; id: string | null }
  | { kind: 'fold'; id: string }
  | { kind: 'open'; id: string }
  | { kind: 'close' }
  | { kind: 'fade'; on: boolean }
  | { kind: 'reset' };
```

### trace-waterfall/trace-waterfall.module.css

```css
/* Trace waterfall (trace-waterfall.tsx): a quiet grey stage with the card at its top, or at night
   a near-black one with the card a step darker still. The card is laid out for 760 px: a header,
   six totals, then the waterfall, a 256 px column of spans beside a timeline. Under 600 px of
   card the column narrows to 152 px, the muted details go and the totals stack in two rows.

   Time: the script writes --t (seconds into the run) and --range (the seconds the axis shows) on
   .waterfall every frame while the run streams; each row carries its span's --s and --e. A bar
   sits at s / range of the timeline and is (min(t, e) − s) / range wide, so it grows until its
   end, and the label after it follows.

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

.root {
  /* The stage, the card, its edge and the lines across it. */
  --stage: #f7f7f7;
  --card: #fdfdfd;
  --edge: #e3e3e3;
  --card-shadow: rgb(0 0 0 / 0.03);
  --line: #ececec;
  --divider: #e0e0e0;
  --grid: #f0f0f0;
  --guide: #e6e6e6;
  /* Text: names, muted details and labels, the bars' labels. */
  --ink: #111113;
  --ink-2: #77777a;
  --ink-3: #a3a3a6;
  --label-ink: #515154;
  --glyph-ink: #19191b;
  /* Rows: hovered, and the one whose card is open. */
  --hover: rgb(0 0 0 / 0.024);
  --selected: rgb(40 110 230 / 0.07);
  --tile: #fff;
  --tile-edge: #e6e6e6;
  --tile-shadow: rgb(0 0 0 / 0.05);
  /* The header: its sparkle, and the Live and Done pills. */
  --sparkle: #3d7af9;
  --sparkle-dot: #9dbcf7;
  --live: #dff2fb;
  --live-ink: #1a66c2;
  --live-dot: #5f9dd0;
  --done: #e2f5e6;
  --done-ink: #23843d;
  --failed: #fde4e1;
  --failed-ink: #c8300f;
  --alert: #d63a1e;
  /* The bars: agents grey, model calls violet, tool calls teal, failures red. */
  --agent: #f0f0f0;
  --agent-edge: #d6d6d6;
  --shine: rgb(255 255 255 / 0.95);
  --llm: #e9e7fb;
  --llm-edge: #c6c0f0;
  --llm-strong: #7a6fe0;
  --tool: #e3f2f4;
  --tool-edge: #a6cfdb;
  --tool-strong: #2b8fae;
  --fail: #fde3e1;
  --fail-edge: #e9877c;
  --fail-strong: #d84a3a;
  /* A folded agent: its bar, and the strip of its spans inside. */
  --folded: #f4f4f4;
  --folded-edge: #dadada;
  --strip-llm: rgb(118 108 222 / 0.88);
  --strip-tool: rgb(30 130 166 / 0.66);
  --strip-fail: rgb(222 72 58 / 0.9);
  --chip: #ececec;
  --chip-ink: #59595c;
  --slow: #f8e6cd;
  --slow-ink: #b4430f;
  --now: #84ade6;
  --now-dot: #2667e2;
  --spinner: #3d7bd9;
  --spinner-track: rgb(61 123 217 / 0.2);
  /* The span card. */
  --pop: #fff;
  --pop-edge: rgb(0 0 0 / 0.09);
  --pop-shadow: rgb(0 0 0 / 0.1);
  --pop-box: #f5f5f5;
  --pop-box-edge: #e8e8e8;
  --pop-ink: #4d4d50;
  --err-box: #fdecea;
  --err-ink: #c8300f;
  /* The same on both schemes: the focus ring, and the demo's pointer, as the system draws it. */
  --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);

  position: relative;
  container-type: inline-size;
  display: grid;
  justify-items: center;
  align-content: start;
  width: 100%;
  min-height: 720px;
  padding: 40px 16px 48px;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--stage);
  color: var(--ink);
  font-family: var(--sans);
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --stage: light-dark(#f7f7f7, #1a1a1c);
    --card: light-dark(#fdfdfd, #18181a);
    --edge: light-dark(#e3e3e3, #2b2b2e);
    --card-shadow: light-dark(rgb(0 0 0 / 0.03), rgb(0 0 0 / 0.3));
    --line: light-dark(#ececec, #28282a);
    --divider: light-dark(#e0e0e0, #2f2f32);
    --grid: light-dark(#f0f0f0, #212123);
    --guide: light-dark(#e6e6e6, #2b2b2e);
    --ink: light-dark(#111113, #ececee);
    --ink-2: light-dark(#77777a, #8a8a8e);
    --ink-3: light-dark(#a3a3a6, #69696d);
    --label-ink: light-dark(#515154, #a5a5a9);
    --glyph-ink: light-dark(#19191b, #e4e4e6);
    --hover: light-dark(rgb(0 0 0 / 0.024), rgb(255 255 255 / 0.025));
    --selected: light-dark(rgb(40 110 230 / 0.07), rgb(80 140 255 / 0.075));
    --tile: light-dark(#fff, #1d1d20);
    --tile-edge: light-dark(#e6e6e6, #2e2e31);
    --tile-shadow: light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.4));
    --sparkle: light-dark(#3d7af9, #4a80f0);
    --sparkle-dot: light-dark(#9dbcf7, #2f4f8c);
    --live: light-dark(#dff2fb, #172b47);
    --live-ink: light-dark(#1a66c2, #6aa8f5);
    --live-dot: light-dark(#5f9dd0, #4d8ef0);
    --done: light-dark(#e2f5e6, #15331f);
    --done-ink: light-dark(#23843d, #4fd884);
    --failed: light-dark(#fde4e1, #361a1a);
    --failed-ink: light-dark(#c8300f, #f57f70);
    --alert: light-dark(#d63a1e, #f57f70);
    --agent: light-dark(#f0f0f0, #252527);
    --agent-edge: light-dark(#d6d6d6, #333335);
    --shine: light-dark(rgb(255 255 255 / 0.95), rgb(255 255 255 / 0.07));
    --llm: light-dark(#e9e7fb, #26253a);
    --llm-edge: light-dark(#c6c0f0, #3a3462);
    --llm-strong: light-dark(#7a6fe0, #7d74e6);
    --tool: light-dark(#e3f2f4, #17292e);
    --tool-edge: light-dark(#a6cfdb, #1f4a55);
    --tool-strong: light-dark(#2b8fae, #2fa5c4);
    --fail: light-dark(#fde3e1, #3a1f24);
    --fail-edge: light-dark(#e9877c, #bc4645);
    --fail-strong: light-dark(#d84a3a, #e5574f);
    --folded: light-dark(#f4f4f4, #222225);
    --folded-edge: light-dark(#dadada, #343437);
    --strip-llm: light-dark(rgb(118 108 222 / 0.88), rgb(112 106 214 / 0.92));
    --strip-tool: light-dark(rgb(30 130 166 / 0.66), rgb(45 174 208 / 0.62));
    --strip-fail: light-dark(rgb(222 72 58 / 0.9), rgb(222 82 70 / 0.92));
    --chip: light-dark(#ececec, #2a2a2d);
    --chip-ink: light-dark(#59595c, #a8a8ab);
    --slow: light-dark(#f8e6cd, #3a2a17);
    --slow-ink: light-dark(#b4430f, #f0a062);
    --now: light-dark(#84ade6, #3e66a6);
    --now-dot: light-dark(#2667e2, #4d8ef7);
    --spinner: light-dark(#3d7bd9, #5b95ec);
    --spinner-track: light-dark(rgb(61 123 217 / 0.2), rgb(91 149 236 / 0.22));
    --pop: light-dark(#fff, #18181a);
    --pop-edge: light-dark(rgb(0 0 0 / 0.09), #2e2e30);
    --pop-shadow: light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.5));
    --pop-box: light-dark(#f5f5f5, #1e1e20);
    --pop-box-edge: light-dark(#e8e8e8, #2a2a2c);
    --pop-ink: light-dark(#4d4d50, #a3a3a8);
    --err-box: light-dark(#fdecea, #36181b);
    --err-ink: light-dark(#c8300f, #f57f70);
  }
}
/* 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 :where(h2, h3, h4, p, ul, li, dl, dt, dd) {
  margin: 0;
  padding: 0;
}
.root :where(ul) {
  list-style: none;
}
.root :where(button) {
  font: inherit;
  color: inherit;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
}

/* ---- The card ---------------------------------------------------------------------------- */

.card {
  position: relative;
  container-type: inline-size;
  width: 100%;
  max-width: 760px;
  border: 1px solid var(--edge);
  border-radius: 12px;
  background: var(--card);
  box-shadow: 0 1px 2px var(--card-shadow);
}

.header {
  display: flex;
  align-items: center;
  gap: 8px;
  height: 44px;
  padding: 0 14px 0 18px;
  border-bottom: 1px solid var(--line);
}
.sparkle {
  flex: none;
  width: 13px;
  height: 13px;
}
.title {
  flex: none;
  font-size: 13px;
  font-weight: 600;
  letter-spacing: -0.005em;
}
.goal {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  color: var(--ink-2);
  font-size: 13px;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.status {
  flex: none;
  display: inline-flex;
  align-items: center;
  gap: 5px;
  height: 20px;
  padding: 0 7px;
  border-radius: 6px;
  font-size: 12px;
  font-weight: 500;
  animation: pill-in 0.3s ease both;
}
.status[data-tone='live'] {
  background: var(--live);
  color: var(--live-ink);
}
.status[data-tone='done'] {
  background: var(--done);
  color: var(--done-ink);
}
.statusDot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--live-dot);
}
@keyframes pill-in {
  from {
    opacity: 0;
    scale: 0.92;
  }
}

/* The totals: six equal columns, a hairline between them. */
.stats {
  display: grid;
  grid-template-columns: repeat(6, 1fr);
  padding: 16px 6px 15px;
}
.stat {
  position: relative;
  min-width: 0;
  padding: 0 16px;
}
.stat + .stat::before {
  content: '';
  position: absolute;
  top: -4px;
  bottom: -4px;
  left: 0;
  width: 1px;
  background: var(--line);
}
.statLabel {
  overflow: hidden;
  color: var(--ink-2);
  font-size: 10.5px;
  font-weight: 500;
  letter-spacing: 0.06em;
  line-height: 14px;
  text-transform: uppercase;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.statValue {
  margin-top: 5px;
  font-size: 15px;
  font-weight: 500;
  line-height: 20px;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.stat[data-alert] .statValue {
  color: var(--alert);
}
/* A digit that changed fades up into place. */
.digit {
  display: inline-block;
  animation: digit-in 0.2s ease-out both;
}
@keyframes digit-in {
  from {
    opacity: 0.5;
    translate: 0 0.16em;
  }
}

/* ---- The waterfall ---------------------------------------------------------------------- */

.waterfall {
  --t: 0;
  --range: 10;
  --left: 256px;
  --indent: 14px;
  --row: 30px;

  position: relative;
  padding-bottom: 6px;
  border-top: 1px solid var(--line);
  transition: opacity 0.3s ease;
}
/* The line between the spans and the timeline. */
.waterfall::before {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  left: var(--left);
  z-index: 1;
  width: 1px;
  background: var(--divider);
  pointer-events: none;
}
.waterfall[data-fading] .rows,
.waterfall[data-fading] .grid,
.waterfall[data-fading] .ticks {
  opacity: 0;
}
.rows,
.grid,
.ticks {
  transition: opacity 0.3s ease;
}

/* The tick lines and now, behind the rows. */
.grid {
  position: absolute;
  top: 0;
  bottom: 0;
  left: var(--left);
  right: 0;
  overflow: hidden;
  pointer-events: none;
}
.gridLine {
  position: absolute;
  top: 0;
  bottom: 0;
  left: calc(var(--v) / var(--range) * 100%);
  width: 1px;
  background: var(--grid);
  animation: fade-in 0.4s ease both;
}
.now {
  position: absolute;
  top: 0;
  bottom: 0;
  left: calc(var(--t) / var(--range) * 100%);
  width: 1px;
  background: var(--now);
  opacity: 0;
  transition: opacity 0.4s ease;
}
.now::after {
  content: '';
  position: absolute;
  top: calc(var(--row) - 2.5px);
  left: -2px;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--now-dot);
}
.waterfall[data-live] .now {
  opacity: 1;
}
@keyframes fade-in {
  from {
    opacity: 0;
  }
}

.axis {
  display: grid;
  grid-template-columns: var(--left) 1fr;
  height: var(--row);
  border-bottom: 1px solid var(--line);
}
.axisLabel {
  align-self: center;
  padding-left: 16px;
  color: var(--ink-2);
  font-size: 10.5px;
  font-weight: 500;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}
.ticks {
  position: relative;
  overflow: hidden;
}
.tick {
  position: absolute;
  top: 0;
  bottom: 0;
  left: calc(var(--v) / var(--range) * 100%);
  display: flex;
  align-items: center;
  padding-left: 6px;
  color: var(--ink-2);
  font-size: 12px;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  animation: fade-in 0.4s ease both;
}

/* ---- Rows --------------------------------------------------------------------------------- */

/* A row opens from nothing when its span starts, and closes when its agent folds. */
.row {
  display: grid;
  grid-template-rows: 1fr;
  transition:
    grid-template-rows 0.28s cubic-bezier(0.3, 0, 0.2, 1),
    opacity 0.22s ease;
  animation: row-in 0.32s cubic-bezier(0.3, 0, 0.2, 1) both;
}
.row[data-hidden] {
  grid-template-rows: 0fr;
  opacity: 0;
  visibility: hidden;
  transition:
    grid-template-rows 0.28s cubic-bezier(0.3, 0, 0.2, 1),
    opacity 0.18s ease,
    visibility 0s 0.28s;
}
@keyframes row-in {
  from {
    grid-template-rows: 0fr;
    opacity: 0;
  }
}
.rowInner {
  position: relative;
  display: grid;
  grid-template-columns: var(--left) 1fr;
  min-height: 0;
  overflow: hidden;
  transition: background-color 0.15s ease;
}
.row:hover .rowInner,
.row[data-hover] .rowInner {
  background: var(--hover);
}
.row[data-open] .rowInner {
  background: var(--selected);
}

/* The whole row is one button that opens the span's card. */
.hit {
  position: absolute;
  inset: 0;
  z-index: 1;
  cursor: pointer;
}
.hit:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: -2px;
  border-radius: 6px;
}

.cell {
  position: relative;
  z-index: 2;
  display: flex;
  align-items: center;
  height: var(--row);
  min-width: 0;
  padding: 0 10px 0 calc(30px + var(--depth) * var(--indent));
  pointer-events: none;
}
.guide {
  position: absolute;
  top: 0;
  bottom: 0;
  left: calc(18px + var(--level) * var(--indent));
  width: 1px;
  background: var(--guide);
}
.chevron {
  position: absolute;
  top: 7px;
  left: calc(10px + var(--depth) * var(--indent));
  display: grid;
  place-items: center;
  width: 16px;
  height: 16px;
  border-radius: 4px;
  color: var(--ink-2);
  cursor: pointer;
  pointer-events: auto;
  transition:
    background-color 0.15s ease,
    color 0.15s ease;
}
.chevron:hover,
.chevron[data-pressed] {
  background: var(--chip);
  color: var(--ink);
}
.chevron:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 0;
}
.chevronGlyph {
  width: 12px;
  height: 12px;
  transition: rotate 0.22s cubic-bezier(0.3, 0, 0.2, 1);
}
.chevron[aria-expanded='false'] .chevronGlyph {
  rotate: -90deg;
}
.tile {
  flex: none;
  display: grid;
  place-items: center;
  width: 20px;
  height: 20px;
  border: 1px solid var(--tile-edge);
  border-radius: 5px;
  background: var(--tile);
  box-shadow: 0 1px 1.5px var(--tile-shadow);
}
.glyph {
  width: 12px;
  height: 12px;
}
.name {
  flex: none;
  margin-left: 9px;
  font-size: 13px;
  font-weight: 430;
  letter-spacing: -0.005em;
  white-space: nowrap;
}
.row[data-kind='agent'] .name {
  font-weight: 550;
}
.detail {
  flex: 0 1 auto;
  min-width: 0;
  margin-left: 6px;
  overflow: hidden;
  color: var(--ink-2);
  font-size: 12.5px;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.more {
  flex: none;
  display: inline-grid;
  place-items: center;
  height: 16px;
  margin-left: 8px;
  padding: 0 5px;
  border-radius: 4px;
  background: var(--chip);
  color: var(--chip-ink);
  font-size: 11px;
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  animation: pill-in 0.25s ease both;
}
.spinner {
  flex: none;
  width: 10px;
  height: 10px;
  margin-left: 7px;
  border: 1.5px solid var(--spinner-track);
  border-top-color: var(--spinner);
  border-right-color: var(--spinner);
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}
@keyframes spin {
  to {
    rotate: 1turn;
  }
}
.warning {
  flex: none;
  width: 13px;
  height: 13px;
  margin-left: 6px;
  color: var(--fail-strong);
}

/* ---- Bars --------------------------------------------------------------------------------- */

.track {
  position: relative;
  z-index: 2;
  height: var(--row);
  overflow: hidden;
  pointer-events: none;
}
.bar {
  position: absolute;
  top: 8px;
  left: calc(var(--s) / var(--range) * 100%);
  width: max(3px, calc((min(var(--t), var(--e)) - var(--s)) / var(--range) * 100%));
  height: 14px;
  border: 1px solid var(--agent-edge);
  border-radius: 4px;
  background: var(--agent);
  transition:
    border-color 0.2s ease,
    background-color 0.2s ease;
}
.row[data-kind='llm'] .bar {
  border-color: var(--llm-edge);
  background: var(--llm);
}
.row[data-kind='tool'] .bar {
  border-color: var(--tool-edge);
  background: var(--tool);
}
.row[data-status='failed'] .bar {
  border-color: var(--fail-edge);
  background: var(--fail);
}
.row[data-open][data-kind='llm'] .bar,
.row[data-hover][data-kind='llm'] .bar {
  border-color: var(--llm-strong);
}
.row[data-open][data-kind='tool'] .bar,
.row[data-hover][data-kind='tool'] .bar {
  border-color: var(--tool-strong);
}
.row[data-open][data-status='failed'] .bar,
.row[data-hover][data-status='failed'] .bar {
  border-color: var(--fail-strong);
}
.row[data-open][data-kind='agent'] .bar,
.row[data-hover][data-kind='agent'] .bar {
  border-color: var(--ink-3);
}
/* Running: a call fades in from its start, with its right edge lit; an agent's summary fades in
   and a light sweeps across it. */
.row[data-status='running'] .bar {
  mask-image: linear-gradient(90deg, rgb(0 0 0 / 0.3), #000 78%);
  border-right-color: var(--llm-strong);
}
.row[data-status='running'][data-kind='tool'] .bar {
  border-right-color: var(--tool-strong);
}
.row[data-status='running'][data-kind='agent'] .bar {
  overflow: hidden;
  mask-image: linear-gradient(90deg, rgb(0 0 0 / 0.15), #000 38%);
  border-right-color: var(--agent-edge);
}
.row[data-status='running'][data-kind='agent']:not([data-folded]) .bar::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(90deg, transparent, var(--shine), transparent) no-repeat;
  background-size: 34% 100%;
  animation: sweep 1.8s linear infinite;
}
@keyframes sweep {
  from {
    background-position: -60% 0;
  }
  to {
    background-position: 160% 0;
  }
}
/* Folded: a quiet bar holding the strip of its spans. */
.row[data-folded] .bar {
  border-color: var(--folded-edge);
  background: var(--folded);
}
.row[data-folded][data-status='running'] .bar {
  mask-image: linear-gradient(90deg, rgb(0 0 0 / 0.5), #000 30%);
}
.segment {
  position: absolute;
  top: 12px;
  left: calc(var(--s) / var(--range) * 100%);
  width: max(2px, calc((min(var(--t), var(--e)) - var(--s)) / var(--range) * 100%));
  height: 6px;
  border-radius: 3px;
  background: var(--strip-tool);
}
.segment[data-kind='llm'] {
  background: var(--strip-llm);
}
.segment[data-status='failed'] {
  background: var(--strip-fail);
}

/* After the bar: its time and cost, on the card's colour so the grid stops behind it. */
.after {
  position: absolute;
  top: 0;
  bottom: 0;
  left: calc(min(var(--t), var(--e)) / var(--range) * 100% + 4px);
  display: flex;
  align-items: center;
  gap: 4px;
  white-space: nowrap;
}
.label {
  padding: 1px 4px;
  border-radius: 4px;
  background: var(--card);
  color: var(--label-ink);
  font-size: 12.5px;
  font-variant-numeric: tabular-nums;
}
.row[data-status='failed'] .label {
  color: var(--alert);
}
.slowest {
  padding: 2px 5px;
  border-radius: 4px;
  background: var(--slow);
  color: var(--slow-ink);
  font-size: 9.5px;
  font-weight: 600;
  letter-spacing: 0.06em;
  line-height: 12px;
  text-transform: uppercase;
  animation: pill-in 0.3s ease both;
}

/* ---- The span card ------------------------------------------------------------------------ */

.pop {
  --pop-w: min(320px, calc(100% - 24px));

  position: absolute;
  top: 0;
  left: clamp(
    12px,
    calc(var(--left) + var(--ps) / var(--range) * (100% - var(--left))),
    calc(100% - var(--pop-w) - 12px)
  );
  z-index: 4;
  width: var(--pop-w);
  padding: 12px;
  border: 1px solid var(--pop-edge);
  border-radius: 12px;
  background: var(--pop);
  box-shadow:
    0 10px 28px var(--pop-shadow),
    0 1px 3px var(--pop-shadow);
  animation: pop-in 0.18s cubic-bezier(0.3, 0, 0.2, 1) both;
}
.pop:focus {
  outline: none;
}
.pop:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
@keyframes pop-in {
  from {
    opacity: 0;
    translate: 0 -4px;
    scale: 0.98;
  }
}
.popHead {
  display: flex;
  align-items: flex-start;
  gap: 10px;
}
.popTile {
  flex: none;
  display: grid;
  place-items: center;
  width: 28px;
  height: 28px;
  border: 1px solid var(--tile-edge);
  border-radius: 7px;
  background: var(--tile);
  box-shadow: 0 1px 1.5px var(--tile-shadow);
}
.popGlyph {
  width: 15px;
  height: 15px;
}
.popTitles {
  flex: 1;
  min-width: 0;
}
.popName {
  overflow: hidden;
  font-size: 13.5px;
  font-weight: 600;
  line-height: 18px;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.popKind {
  overflow: hidden;
  color: var(--ink-2);
  font-size: 12.5px;
  line-height: 16px;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.pill {
  flex: none;
  display: inline-grid;
  place-items: center;
  height: 18px;
  margin-top: 4px;
  padding: 0 6px;
  border-radius: 5px;
  font-size: 11.5px;
  font-weight: 500;
}
.pill[data-tone='done'] {
  background: var(--done);
  color: var(--done-ink);
}
.pill[data-tone='running'] {
  background: var(--live);
  color: var(--live-ink);
}
.pill[data-tone='failed'] {
  background: var(--failed);
  color: var(--failed-ink);
}
.close {
  flex: none;
  display: grid;
  place-items: center;
  width: 20px;
  height: 20px;
  margin-top: 3px;
  border-radius: 5px;
  color: var(--ink-2);
  cursor: pointer;
  transition:
    background-color 0.15s ease,
    color 0.15s ease;
}
.close:hover,
.close[data-pressed] {
  background: var(--chip);
  color: var(--ink);
}
.close:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 0;
}
.closeGlyph {
  width: 13px;
  height: 13px;
}
.facts {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 10px 8px;
  margin-top: 12px;
  padding: 9px 10px 10px;
  border: 1px solid var(--pop-box-edge);
  border-radius: 9px;
  background: var(--pop-box);
}
.fact {
  min-width: 0;
}
.factLabel {
  overflow: hidden;
  color: var(--ink-2);
  font-size: 10px;
  font-weight: 500;
  letter-spacing: 0.06em;
  line-height: 13px;
  text-transform: uppercase;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.factValue {
  margin-top: 3px;
  font-size: 13.5px;
  font-weight: 500;
  line-height: 18px;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.error {
  display: flex;
  gap: 8px;
  margin-top: 10px;
  padding: 8px 10px;
  border-radius: 8px;
  background: var(--err-box);
  color: var(--err-ink);
  font-size: 12.5px;
  line-height: 17px;
}
.errorGlyph {
  flex: none;
  width: 13px;
  height: 13px;
  margin-top: 2px;
}
.io {
  margin-top: 11px;
  padding: 0 1px;
}
.ioLabel {
  color: var(--ink-2);
  font-size: 10px;
  font-weight: 500;
  letter-spacing: 0.06em;
  line-height: 13px;
  text-transform: uppercase;
}
.ioText {
  margin-top: 3px;
  color: var(--pop-ink);
  font-size: 12.5px;
  line-height: 17px;
}

/* ---- Narrow cards -------------------------------------------------------------------------- */

@container (max-width: 600px) {
  .waterfall {
    --left: 152px;
    --indent: 10px;
  }
  .stats {
    grid-template-columns: repeat(3, 1fr);
    row-gap: 14px;
    padding: 14px 4px 13px;
  }
  .stat {
    padding: 0 12px;
  }
  .stat:nth-child(4)::before {
    display: none;
  }
  .header {
    padding: 0 12px 0 14px;
  }
  .cell {
    padding-left: calc(28px + var(--depth) * var(--indent));
    padding-right: 6px;
  }
  .chevron {
    left: calc(8px + var(--depth) * var(--indent));
  }
  .guide {
    left: calc(16px + var(--level) * var(--indent));
  }
  .name {
    flex: 0 1 auto;
    min-width: 0;
    margin-left: 7px;
    overflow: hidden;
    font-size: 12.5px;
    text-overflow: ellipsis;
  }
  .detail {
    display: none;
  }
  .label {
    font-size: 12px;
  }
}
@container (max-width: 380px) {
  .goal {
    display: none;
  }
  .waterfall {
    --left: 136px;
  }
  .statLabel {
    font-size: 10px;
    letter-spacing: 0.04em;
  }
}
@media (max-width: 560px) {
  .root {
    min-height: 640px;
    padding: 20px 10px 32px;
  }
}

/* ---- The demo's pointer ---------------------------------------------------------------------- */

.pointerLayer {
  position: absolute;
  inset: 0;
  z-index: 5;
  pointer-events: none;
}
.pointer {
  width: 22px;
  height: 22px;
}
.hand {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .arrow {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .hand {
  visibility: visible;
}

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

@media (prefers-reduced-motion: reduce) {
  .row,
  .row[data-hidden],
  .rowInner,
  .bar,
  .chevronGlyph,
  .waterfall,
  .rows,
  .grid,
  .ticks,
  .now {
    transition: none;
  }
  .row,
  .digit,
  .status,
  .more,
  .slowest,
  .gridLine,
  .tick,
  .pop,
  .bar::after,
  .row[data-status='running'][data-kind='agent']:not([data-folded]) .bar::after {
    animation: none;
  }
  .spinner {
    animation-duration: 2.4s;
  }
}
```

### trace-waterfall/data.ts

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

/*
 * The shapes the trace draws, and the run the lab shows: one agent launching a pricing page with
 * three sub-agents under it. Times are in seconds from the start of the run; a span shows once
 * the run's clock reaches its start, and runs until its end.
 */

/** What a span is: an agent (with spans under it), a call to a model, or a call to a tool. */
export type SpanKind = 'agent' | 'llm' | 'tool';

/** The drawing on a span's tile, in glyphs.tsx: a model family's mark, or a tool's glyph. */
export type SpanIcon =
  | 'model-a'
  | 'model-b'
  | 'model-c'
  | 'search'
  | 'database'
  | 'web'
  | 'doc'
  | 'design'
  | 'repo'
  | 'payments'
  | 'crm'
  | 'chat';

export type Span = {
  id: string;
  /** The agent span it sits under; none for the root. */
  parent?: string;
  kind: SpanKind;
  name: string;
  /** The model or the tool, or what was asked of it: muted after the name. */
  detail: string;
  icon: SpanIcon;
  /** When it started and ended, in seconds from the start of the run; no end while it runs. */
  start: number;
  end?: number;
  /** What it cost, in dollars; an agent's is the sum of the spans under it. */
  cost?: number;
  /** Tokens read and written, for a model call; an agent sums the spans under it. */
  tokensIn?: number;
  tokensOut?: number;
  /** Why it failed: the span ends red, with this in its card. */
  error?: string;
  /** What it was given and what it gave back, in its card (the output once it ends). */
  input?: string;
  output?: string;
};

export const GOAL = 'Launch the new pricing page by Friday';

export const SPANS: readonly Span[] = [
  {
    id: 'orchestrator',
    kind: 'agent',
    name: 'Orchestrator',
    detail: 'Model A',
    icon: 'model-a',
    start: 0,
    end: 82,
    input: 'Launch the new pricing page by Friday',
    output: 'Pricing page ready for review in PR #482, plans updated, team told',
  },
  {
    id: 'plan',
    parent: 'orchestrator',
    kind: 'llm',
    name: 'Plan the launch',
    detail: 'Model A',
    icon: 'model-a',
    start: 0.8,
    end: 7.7,
    cost: 0.12,
    tokensIn: 3200,
    tokensOut: 900,
    input: 'The goal, the current plans and last quarter’s usage',
    output: 'Three workstreams, research, copy and build, for three sub-agents',
  },
  {
    id: 'researcher',
    parent: 'orchestrator',
    kind: 'agent',
    name: 'Researcher',
    detail: 'Model A mini',
    icon: 'model-a',
    start: 8.1,
    end: 38.1,
    input: 'Find what rivals charge and how our teams buy',
    output: 'Price per seat wins; most teams have 5 to 20 seats',
  },
  {
    id: 'search-a',
    parent: 'researcher',
    kind: 'tool',
    name: 'web_search',
    detail: 'Rival A pricing',
    icon: 'search',
    start: 9,
    end: 14.4,
    cost: 0.005,
    input: 'rival A pricing per seat 2026',
    output: '8 results, the pricing page first',
  },
  {
    id: 'search-b',
    parent: 'researcher',
    kind: 'tool',
    name: 'web_search',
    detail: 'Rival B pricing',
    icon: 'search',
    start: 9.5,
    end: 16.7,
    cost: 0.005,
    input: 'rival B team plan price',
    output: '6 results, a plan table and its FAQ',
  },
  {
    id: 'usage',
    parent: 'researcher',
    kind: 'tool',
    name: 'Query plan usage',
    detail: 'Database',
    icon: 'database',
    start: 10.5,
    end: 14,
    input: 'Seats per paying team, last 90 days',
    output: 'Median 7 seats, 82% of teams under 20',
  },
  {
    id: 'reviews',
    parent: 'researcher',
    kind: 'tool',
    name: 'Read user reviews',
    detail: '120 pages',
    icon: 'web',
    start: 16.5,
    end: 29.5,
    cost: 0.02,
    input: '120 pages of reviews that mention price',
    output: 'Most asked for: a free tier and one simple seat price',
  },
  {
    id: 'summary',
    parent: 'researcher',
    kind: 'llm',
    name: 'Summarize findings',
    detail: 'Model C fast',
    icon: 'model-c',
    start: 28,
    end: 37.3,
    cost: 0.02,
    tokensIn: 14200,
    tokensOut: 1100,
    input: 'Two searches, the usage query and the review notes',
    output: 'Free, Pro per seat, and Team with single sign-on',
  },
  {
    id: 'writer',
    parent: 'orchestrator',
    kind: 'agent',
    name: 'Writer',
    detail: 'Model A mini',
    icon: 'model-a',
    start: 11.4,
    end: 48.4,
    input: 'Write the copy for the three plans',
    output: 'Copy for Free, Pro and Team, saved in Docs',
  },
  {
    id: 'voice',
    parent: 'writer',
    kind: 'tool',
    name: 'Read brand voice',
    detail: 'Docs',
    icon: 'doc',
    start: 11.9,
    end: 15.5,
    input: 'Brand / Voice and tone',
    output: 'Short sentences, plain words, no exclamation marks',
  },
  {
    id: 'frames',
    parent: 'writer',
    kind: 'tool',
    name: 'Read pricing frames',
    detail: 'Design file',
    icon: 'design',
    start: 12.2,
    end: 17.3,
    input: 'Pricing / Desktop, Pricing / Mobile',
    output: '3 frames, 6 text slots per plan',
  },
  {
    id: 'draft',
    parent: 'writer',
    kind: 'llm',
    name: 'Draft plan copy',
    detail: 'Model A mini',
    icon: 'model-a',
    start: 17.8,
    end: 40.8,
    cost: 0.09,
    tokensIn: 9800,
    tokensOut: 4100,
    input: 'Voice guide, research summary, 3 frames with 6 slots each',
    output:
      'Free: “Try it with your team” · Pro: “Pay per seat as you grow” · Team: “Admin and sign-on”',
  },
  {
    id: 'save',
    parent: 'writer',
    kind: 'tool',
    name: 'Save draft',
    detail: 'Docs',
    icon: 'doc',
    start: 41.4,
    end: 45,
    input: 'Pricing copy, first draft',
    output: 'Saved in Launch / Pricing copy',
  },
  {
    id: 'coder',
    parent: 'orchestrator',
    kind: 'agent',
    name: 'Coder',
    detail: 'Model B',
    icon: 'model-b',
    start: 15.3,
    end: 74.3,
    input: 'Build the pricing page and its checkout',
    output: 'PR #482: the page, the new prices and a checkout fix',
  },
  {
    id: 'branch',
    parent: 'coder',
    kind: 'tool',
    name: 'Create branch',
    detail: 'Repo',
    icon: 'repo',
    start: 16,
    end: 18.1,
    input: 'pricing-page, from main',
    output: 'Branch pricing-page created',
  },
  {
    id: 'page',
    parent: 'coder',
    kind: 'llm',
    name: 'Write pricing page',
    detail: 'Model B',
    icon: 'model-b',
    start: 18.5,
    end: 38.5,
    cost: 0.16,
    tokensIn: 31600,
    tokensOut: 12400,
    input: 'The copy, 3 frames and the design tokens',
    output: '4 files changed, 312 lines added',
  },
  {
    id: 'prices',
    parent: 'coder',
    kind: 'tool',
    name: 'Create prices',
    detail: 'Payments',
    icon: 'payments',
    start: 38.7,
    end: 41.7,
    input: 'Pro $12 and Team $24 per seat, monthly and yearly',
    output: '4 prices created in test mode',
  },
  {
    id: 'tests',
    parent: 'coder',
    kind: 'tool',
    name: 'Run checkout tests',
    detail: 'Repo',
    icon: 'repo',
    start: 42.4,
    end: 50.8,
    error: '2 of 14 checkout tests fail: coupon codes aren’t applied to seat prices',
    input: 'test checkout',
  },
  {
    id: 'fix',
    parent: 'coder',
    kind: 'llm',
    name: 'Fix coupon handling',
    detail: 'Model B',
    icon: 'model-b',
    start: 51.4,
    end: 59.2,
    cost: 0.07,
    tokensIn: 18600,
    tokensOut: 3800,
    input: 'The failing tests and the checkout code',
    output: 'Apply the coupon after counting the seats',
  },
  {
    id: 'retest',
    parent: 'coder',
    kind: 'tool',
    name: 'Run checkout tests',
    detail: 'retry',
    icon: 'repo',
    start: 59.7,
    end: 66.9,
    input: 'test checkout',
    output: '14 of 14 passed',
  },
  {
    id: 'pr',
    parent: 'coder',
    kind: 'tool',
    name: 'Open pull request',
    detail: '#482',
    icon: 'repo',
    start: 67.6,
    end: 73,
    input: 'pricing-page into main',
    output: 'PR #482: Pricing page and seat prices',
  },
  {
    id: 'crm',
    parent: 'orchestrator',
    kind: 'tool',
    name: 'Update plans',
    detail: 'CRM',
    icon: 'crm',
    start: 74.5,
    end: 77.8,
    input: 'Plans: Free, Pro and Team, with their prices',
    output: '3 plans updated',
  },
  {
    id: 'post',
    parent: 'orchestrator',
    kind: 'tool',
    name: 'Post to #launch',
    detail: 'Team chat',
    icon: 'chat',
    start: 78.2,
    end: 81.2,
    input: 'What the run did, with the PR',
    output: 'Posted, 4 people tagged',
  },
];
```

### trace-waterfall/format.ts

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

/*
 * How the trace writes its numbers: durations as a person reads them (637ms, 6.9s, 13s, 1m 22s),
 * money to the cent (or the tenth of a cent under one), tokens in thousands.
 */

/** Seconds as 637ms, 6.9s, 13s or 1m 03s. Rounded down, so a live value never runs ahead. */
export function duration(seconds: number) {
  const s = Math.max(0, seconds);
  if (s < 1) return `${Math.floor(s * 1000 + 1e-6)}ms`;
  if (s < 10) return `${(Math.floor(s * 10 + 1e-6) / 10).toFixed(1)}s`;
  if (s < 60) return `${Math.floor(s + 1e-6)}s`;
  const whole = Math.floor(s + 1e-6);
  return `${Math.floor(whole / 60)}m ${String(whole % 60).padStart(2, '0')}s`;
}

/** The axis's ticks: 0s, 20s, 1m, 1m 20s. */
export function tick(seconds: number) {
  if (seconds < 60) return `${seconds}s`;
  const rest = seconds % 60;
  return rest ? `${Math.floor(seconds / 60)}m ${rest}s` : `${seconds / 60}m`;
}

/** Dollars: $0.12, or $0.005 under a cent; nothing for nothing. */
export function money(dollars: number) {
  if (dollars <= 0) return '';
  return dollars < 0.01 ? `$${dollars.toFixed(3)}` : `$${dollars.toFixed(2)}`;
}

/** Tokens: 620, 4.1k, 99.7k. */
export function tokens(count: number) {
  if (count < 1000) return `${Math.round(count)}`;
  return `${(Math.floor(count / 100 + 1e-6) / 10).toFixed(1)}k`;
}

/** A duration and a cost, as a bar's label says them: 6.9s · $0.12. */
export function spent(seconds: number, dollars: number) {
  const cost = money(dollars);
  return cost ? `${duration(seconds)} · ${cost}` : duration(seconds);
}
```

### trace-waterfall/glyphs.tsx

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

/*
 * The small drawings: a mark per model family and a glyph per tool, for the spans' tiles, plus
 * the header's sparkle, the chevron, the warning sign and the close cross. All on a 16-unit grid,
 * sized by the stylesheet. Ink-coloured strokes take --glyph-ink, so they turn light at night;
 * the coloured ones keep their colour on both schemes.
 */

import type { SpanIcon } from './data';

const INK = { stroke: 'var(--glyph-ink)' };

function IconPath({ icon }: { icon: SpanIcon }) {
  switch (icon) {
    case 'model-a':
      // Four rounded petals around a small open heart.
      return (
        <g fill="#d9614c">
          <ellipse cx="8" cy="4" rx="2.1" ry="3.3" />
          <ellipse cx="8" cy="12" rx="2.1" ry="3.3" />
          <ellipse cx="4" cy="8" rx="3.3" ry="2.1" />
          <ellipse cx="12" cy="8" rx="3.3" ry="2.1" />
          <circle cx="8" cy="8" r="1.3" fill="var(--tile)" />
        </g>
      );
    case 'model-b':
      return (
        <g fill="none" style={INK} strokeWidth="1.5">
          <circle cx="6" cy="8" r="4.3" />
          <circle cx="10" cy="8" r="4.3" />
        </g>
      );
    case 'model-c':
      return (
        <path
          d="M8 1.4c.6 3.6 2.9 5.9 6.6 6.6-3.7.7-6 3-6.6 6.6-.6-3.6-2.9-5.9-6.6-6.6 3.7-.7 6-3 6.6-6.6z"
          fill="#3a6df0"
        />
      );
    case 'search':
      return (
        <g fill="none" stroke="#1f8f74" strokeWidth="1.7" strokeLinecap="round">
          <circle cx="7.2" cy="7.2" r="4.3" />
          <path d="M10.4 10.4l3.2 3.2" />
        </g>
      );
    case 'database':
      return (
        <g fill="none" stroke="#3f63a8" strokeWidth="1.5">
          <ellipse cx="8" cy="4.2" rx="4.8" ry="2" />
          <path d="M3.2 4.2v7.6c0 1.1 2.1 2 4.8 2s4.8-.9 4.8-2V4.2M3.2 8c0 1.1 2.1 2 4.8 2s4.8-.9 4.8-2" />
        </g>
      );
    case 'web':
      return (
        <g fill="none" stroke="#2f6fe0" strokeWidth="1.4">
          <circle cx="8" cy="8" r="5.8" />
          <ellipse cx="8" cy="8" rx="2.5" ry="5.8" />
          <path d="M2.4 6.2h11.2M2.4 9.8h11.2" />
        </g>
      );
    case 'doc':
      return (
        <>
          <path
            d="M3.4 2.2h6.4l2.8 2.8v8.8H3.4z"
            fill="none"
            style={INK}
            strokeWidth="1.5"
            strokeLinejoin="round"
          />
          <path d="M5.8 7.4h4.4M5.8 10.2h3.2" style={INK} strokeWidth="1.4" strokeLinecap="round" />
        </>
      );
    case 'design':
      return (
        <>
          <circle cx="5" cy="5" r="3" fill="#ff5f57" />
          <rect x="8.8" y="2" width="5.2" height="6" rx="1.5" fill="#8b5cf6" />
          <path d="M4.8 9.2l3.4 5H1.4z" fill="#14b8e8" strokeLinejoin="round" />
          <circle cx="11.4" cy="11.6" r="2.5" fill="#22c55e" />
        </>
      );
    case 'repo':
      // A branch: two commits on a line, a third forking off it.
      return (
        <g fill="none" style={INK} strokeWidth="1.5" strokeLinecap="round">
          <circle cx="4.6" cy="3.6" r="1.7" />
          <circle cx="4.6" cy="12.4" r="1.7" />
          <circle cx="11.4" cy="5.2" r="1.7" />
          <path d="M4.6 5.3v5.4M11.4 6.9c0 2.6-2.2 3.1-4.4 3.6-1 .2-1.8.5-2.2 1" />
        </g>
      );
    case 'payments':
      return (
        <>
          <rect x="1" y="1" width="14" height="14" rx="3.4" fill="#6a4cf0" />
          <rect
            x="3.6"
            y="5"
            width="8.8"
            height="6"
            rx="1.2"
            fill="none"
            stroke="#fff"
            strokeWidth="1.3"
          />
          <path d="M3.8 7.2h8.4" stroke="#fff" strokeWidth="1.3" />
        </>
      );
    case 'crm':
      // A hub: one node with three spokes.
      return (
        <g stroke="#ef5a3c" strokeWidth="1.6" strokeLinecap="round">
          <circle cx="9.4" cy="9.2" r="2.6" fill="none" />
          <path d="M9.4 6.6V3.2M7.4 10.9l-3.6 2.9M11.6 7.6l2.2-1.6" fill="none" />
          <circle cx="9.4" cy="2.6" r="1.3" fill="#ef5a3c" stroke="none" />
          <circle cx="3.2" cy="14" r="1.4" fill="#ef5a3c" stroke="none" />
        </g>
      );
    case 'chat':
      return (
        <>
          <path
            d="M2 4.6A2.6 2.6 0 0 1 4.6 2h6.8A2.6 2.6 0 0 1 14 4.6v4.2a2.6 2.6 0 0 1-2.6 2.6H7.2L4 14.2v-2.9A2.6 2.6 0 0 1 2 8.8z"
            fill="#1f9d6b"
          />
          <path
            d="M6.4 4.6l-.8 4.6M9.6 4.6l-.8 4.6M5 6h6M4.6 7.8h6"
            stroke="#fff"
            strokeWidth="1"
            strokeLinecap="round"
          />
        </>
      );
  }
}

export function SpanGlyph({ icon, className }: { icon: SpanIcon; className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true" focusable="false">
      <IconPath icon={icon} />
    </svg>
  );
}

/** The header's mark: a four-point sparkle with a small one beside it. */
export function Sparkle({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true" focusable="false">
      <path
        d="M6.6 1.2c.5 3.1 2.1 4.7 5.2 5.2-3.1.5-4.7 2.1-5.2 5.2-.5-3.1-2.1-4.7-5.2-5.2 3.1-.5 4.7-2.1 5.2-5.2z"
        fill="var(--sparkle)"
      />
      <circle cx="12.8" cy="13" r="1.5" fill="var(--sparkle-dot)" />
    </svg>
  );
}

export function Chevron({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true" focusable="false">
      <path
        d="M4.5 6.2L8 9.8l3.5-3.6"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

export function Warning({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true" focusable="false">
      <path
        d="M8 2.4l6.2 10.9H1.8z"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.4"
        strokeLinejoin="round"
      />
      <path d="M8 6.6v3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
      <circle cx="8" cy="11.4" r="0.85" fill="currentColor" />
    </svg>
  );
}

export function Cross({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true" focusable="false">
      <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
    </svg>
  );
}
```

### trace-waterfall/demo-pointer.tsx

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

'use client';

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

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

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

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

### trace-waterfall/use-autoplay.ts

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

'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, visible, reduced, playing: visible && !reduced && !taken };
}
```