# Agent lanes

> A team of agents on one time axis: each task a bar that runs, finishes, fails or waits for you, with handoffs between them.

A card that shows what a team of agents did over the last half hour. Each agent has a lane on a shared axis that ends at now; its tasks are bars, grey when done, red when they failed, blue and still growing while they run, orange while they wait for you. Time keeps flowing, so finished work drifts left. Hover a bar for what it did, open a waiting task to approve or reject it, and follow the dashed curve where one agent hands its work to another.

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

Category: AI.

## Anatomy

1. **Counts**: How many tasks run and how many need you, in words beside a coloured dot. A count at zero is left out.
2. **Window**: The span the axis shows, from the window prop. Ticks fall at about six steps across it.
3. **Now**: The right end of the axis. Running and waiting bars reach it; the track slides left as time passes, so finished bars drift away.
4. **Agent**: A tile with the model family’s mark, the agent’s name and the model it runs on.
5. **Badge**: Idle when nothing runs or waits in the lane, Needs you when a task waits on the reader.
6. **Running bar**: Blue, with a bright right edge on now. Its width is the time it has run, so it grows as the clock moves.
7. **Needs-you chip**: Sits on the waiting bar’s end and breathes; it opens the question with Approve and Reject.
8. **Handoff**: A dashed curve from the end of one task to the start of the next, across lanes. A new one draws itself in.

## When to use

- A team of agents working at once, where the reader needs to see who is busy, who is stuck and who is waiting on them.
- Recent work on a shared clock: the last 15 minutes to a couple of hours, ending at now.
- Agents that pause for approval, where a bar that asks is better than a separate inbox.
- Work that passes from one agent to another, so the handoff is worth drawing.
- A side panel or a dashboard tile, about 560 to 670 px wide, with two to six lanes.

## When not to use

- One agent, or one task at a time. Use a progress line or a step list in the chat.
- Long history, days or weeks of runs. Use a table or a log with filters and dates.
- Exact timing to the second, as in a trace. Use a waterfall or a profiler view.
- A queue of approvals with nothing else to show. Use an approval card or an inbox.
- Many agents, past eight or so: the lanes get too thin. Group them, or use a table with a status column.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/agent-lanes.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/agent-lanes
```

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 { AgentLanes, type Task } from './agent-lanes';

const tasks: Task[] = [
  {
    id: 'deploy',
    agent: 'ops',
    label: 'Deploy release',
    tools: ['code', 'key'],
    status: 'needs',
    start: -6,
    notes: ['Built and tested', 'Waiting for your go-ahead'],
    ask: { title: 'Deploy release', body: 'Ship build 214 to production?' },
  },
];

export function TeamBoard() {
  return (
    <AgentLanes
      agents={[{ id: 'ops', name: 'Ops', model: 'Model C fast', mark: 'diamond' }]}
      tasks={tasks}
      onDecision={(task, decision) => console.log(task.id, decision)}
      autoplay={false}
    />
  );
}
```

## States

- **Busy board**: Running, waiting, done and failed work at once, with one handoff.
- **Running**: Two agents, both at work: blue bars on now, no badges, and the header says 2 running.
- **Needs you**: A task waits for approval: an orange bar with its chip, a badge on the agent, a count in the header.
- **Question open**: The chip opened the popover above its bar. Approve runs the task, Reject ends it as failed.
- **Tooltip**: Hover, focus or tap a bar: the agent, the status, how long, and what it did.
- **Failed**: Red bars, one that broke and one rejected. Nothing runs, so the lane is Idle.
- **Handoff**: The writer finished its draft and the reviewer took it: a dashed curve joins the two bars.
- **All idle**: Everything finished: grey bars, every agent Idle, and no counts in the header.

## How it works

- Time is a clock in minutes from the moment the board mounts, and it moves a single custom property, --now, on the lanes. --k is the lanes’ px per minute, measured from their width; each bar is placed at (start + window − now) × k by a transform, and a running bar is (now − start) × k wide, so its right end stays on now while finished bars drift left.
- The clock writes --now every frame only while that moves a bar by a quarter of a pixel or more; at real speed that is about once a second, so a board left open costs almost nothing. React re-renders only when the data changes, and for a tooltip’s minutes while one is open.
- A bar’s status is a data attribute: its background, text and the bright right edge of a running bar cross-fade with transitions, so approving a waiting task or finishing one turns it in place. A running bar has a soft band of light sweeping across it.
- Handoffs are SVG curves on a track of their own over all the lanes, from the end of one bar to the start of the next, sliding with time like the bars. A new one draws itself in through a mask whose stroke dash runs from 0 to the whole path.
- Lanes clip their bars and fade them out at the left edge with a mask, as they slide out of the window. Under 560 px of card, each agent sits over its lane instead of beside it.
- The demo hovers bars, opens the waiting task and approves it, then lets the writer hand its draft to the reviewer, on a clock 30 times faster than real time. Any input of the reader’s stops it, and time goes back to the speed prop.

## API reference

- **agents** (`readonly Agent[]`, default `AGENTS`): The agents, one lane each: an id, a name, the model under it, and the mark on its tile (petal, rings, diamond, hex).
- **tasks** (`readonly Task[]`, default `TASKS`): Their tasks: the agent, a label, the tools it used, a status (running, needs, done, failed), a start and, when finished, an end, in minutes from mount (negative: before it); notes for the tooltip, and for a task that needs you, the question it asks.
- **handoffs** (`readonly Handoff[]`, default `HANDOFFS`): Which finished task handed its work to which: a dashed curve between them.
- **window** (`number`, default `30`): How many minutes the axis shows, up to now.
- **speed** (`number`, default `1`): How fast time runs on the board: 1 is real time. The demo runs at 30.
- **title** (`string`, default `'Agents'`): The header’s title.
- **onDecision** (`(task: Task, decision: 'approve' | 'reject') => void`): Called when the reader approves a waiting task (it runs) or rejects it (it ends as failed).
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer shows the board at work; 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.
- **defaultOpenTask** (`string`): Opens with the popover of this waiting task showing (its id), to show the question in a static view; turns the demo off.
- **defaultTooltip** (`string`): Opens with the tooltip of this task showing (its id); turns the demo off.
- **className** (`string`): Classes for the stage.

## Accessibility

- The card is a section named by its title; the agents are a list, and each lane’s tasks a list of buttons named by their label and status.
- Hovering, focusing with the keyboard or tapping a bar shows its tooltip, tied to the bar with aria-describedby; Escape hides it.
- The “Needs you” chip is a button with aria-haspopup and aria-expanded. Its popover is a non-modal dialog named by its title: opening it from the chip puts the focus on Approve, Escape closes it and gives the focus back, and a decision returns the focus to the bar.
- Approving, rejecting and a handoff are announced in a polite live region. The header’s counts say how many agents run and how many need you in words, not only with coloured dots.
- Every control has a visible focus ring. Under reduced motion, the demo, the sweeping light, the chip’s breathing and the curves’ drawing are off; time still moves, at the speed prop.
- The demo pointer is decorative and hidden from assistive technology.

## Source

### agent-lanes/agent-lanes.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import { AGENTS, HANDOFFS, REVIEW_TASK, TASKS, type Agent, type Handoff, type Task } from './data';
import { DemoPointer, runDemo, type Point } from './demo-pointer';
import { AgentGlyph, Sparkle, ToolGlyph, Warning } from './glyphs';
import { useAutoplay } from './use-autoplay';
import styles from './agent-lanes.module.css';

/*
 * Agent lanes: what a team of agents did over the last half hour, one lane per agent on a shared
 * time axis that ends at now. Each task is a bar: grey when done, red when it failed, blue and
 * still growing while it runs, orange while it waits for you. A dashed curve shows one task
 * handing its work to another.
 *
 * - Time is a clock in minutes, from the moment the board mounts. It moves one CSS variable,
 *   --now, on the lanes; --k is the lanes' px per minute, from their width. Each lane's bars sit
 *   on a track that slides left by now × k (a transform), so finished bars drift away; a running
 *   bar's width is (now − start) × k, so its right end stays on now. Nothing else re-renders as
 *   time passes, but a tooltip's minutes, once a minute.
 * - The clock writes --now every frame while that moves a bar by a quarter of a px or more, and
 *   less often when it would not: at real speed, about once a second.
 * - The handoff curves sit on one more track over all the lanes, an SVG path from the end of one
 *   bar to the start of the other. A new one draws itself in through a mask.
 * - Hover, focus or tap a bar for what it did; the "Needs you" chip opens what the agent asks,
 *   with Approve and Reject. Approving runs the task; rejecting ends it as failed.
 * - While on screen and untouched, a demo pointer hovers bars, approves the waiting task and
 *   watches the writer hand its draft to the reviewer, on a clock 30 times faster than real
 *   time. Any real input hands the board to the reader, and time goes back to real speed.
 */

export type { Agent, AgentMark, Handoff, Task, TaskStatus, Tool } from './data';

/** How fast the demo's clock runs: 30 minutes of the board in a minute. */
const DEMO_SPEED = 30;
/** Time the bars move by, at most, between two writes of the clock: in px. */
const STEP_PX = 0.25;

const STATUS_WORD: Record<Task['status'], string> = {
  running: 'Running',
  needs: 'Needs you',
  done: 'Done',
  failed: 'Failed',
};

/** The axis's step, in minutes: about six ticks across the window. */
function tickStep(window: number) {
  return [1, 2, 5, 10, 15, 30, 60, 120, 240].find((step) => window / step <= 6.5) ?? 480;
}

function minutes(value: number) {
  const rounded = Math.round(value);
  return rounded < 1 ? '<1m' : `${rounded}m`;
}

type Tip = { id: string; x: number; y: number; via: 'pointer' | 'focus' | 'tap' | 'demo' };

export function AgentLanes({
  agents = AGENTS,
  tasks: initialTasks = TASKS,
  handoffs: initialHandoffs = HANDOFFS,
  window: span = 30,
  speed = 1,
  title = 'Agents',
  onDecision,
  autoplay = true,
  colorScheme,
  defaultOpenTask,
  defaultTooltip,
  className = '',
}: {
  /** The agents, one lane each, in this order. */
  agents?: readonly Agent[];
  /** Their tasks, with times in minutes from mount (negative: before it). */
  tasks?: readonly Task[];
  /** Which task handed its work to which: a dashed curve from one to the other. */
  handoffs?: readonly Handoff[];
  /** How many minutes the axis shows, up to now. */
  window?: number;
  /** How fast time runs on the board: 1 is real time. The demo runs at 30. */
  speed?: number;
  /** The header's title. */
  title?: string;
  /** Called when the reader approves or rejects a task that needs them. */
  onDecision?: (task: Task, decision: 'approve' | 'reject') => void;
  /** While on screen and untouched, a demo pointer shows the board at work. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Opens with the popover of this waiting task showing (its id); turns the demo off. */
  defaultOpenTask?: string;
  /** Opens with the tooltip of this task showing (its id); turns the demo off. */
  defaultTooltip?: string;
  className?: string;
}) {
  const { ref: root, playing, visible, take } = useAutoplay<HTMLDivElement>(6000);
  const demo = autoplay && playing && !defaultOpenTask && !defaultTooltip;
  const uid = useId();

  const [tasks, setTasks] = useState<Task[]>(() => [...initialTasks]);
  const [handoffs, setHandoffs] = useState<Handoff[]>(() => [...initialHandoffs]);
  /** Handoffs added since mount: they draw themselves in. */
  const [fresh, setFresh] = useState<ReadonlySet<string>>(() => new Set());
  const [tip, setTip] = useState<Tip | null>(null);
  const [asking, setAsking] = useState<string | null>(null);
  const [message, setMessage] = useState('');
  /** The clock, as the tooltip shows it: updated each tenth of a minute while one shows. */
  const [shown, setShown] = useState(0);
  /** The demo's pointer over the chip, or over Approve. */
  const [demoOver, setDemoOver] = useState<'chip' | 'approve' | null>(null);
  /** The lanes fade out and back while the demo starts over. */
  const [fading, setFading] = useState(false);
  /** Lane geometry, for the handoff curves: px per minute, each lane's middle. */
  const [geo, setGeo] = useState<{ k: number; mids: Record<string, number> }>({
    k: 0,
    mids: {},
  });

  const card = useRef<HTMLDivElement>(null);
  const body = useRef<HTMLDivElement>(null);
  const axis = useRef<HTMLDivElement>(null);
  const tipBox = useRef<HTMLDivElement>(null);
  const askBox = useRef<HTMLDivElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const bars = useRef(new Map<string, HTMLElement>());
  const chips = useRef(new Map<string, HTMLButtonElement>());
  const approveButton = useRef<HTMLButtonElement>(null);
  const clock = useRef({ now: 0, shown: 0 });
  const kRef = useRef(16);
  const speedRef = useRef(speed);
  const tipOn = useRef(false);
  useEffect(() => {
    speedRef.current = demo ? DEMO_SPEED : speed;
  }, [demo, speed]);
  useEffect(() => {
    tipOn.current = tip !== null;
    if (tip) setShown(clock.current.now);
  }, [tip]);
  /** The reader opened the popover: it takes the focus, and gives it back. */
  const askFocus = useRef(false);

  // New data from the props: start over (set while rendering, so it never shows the old data).
  // Compared by content, so arrays written inline in the caller's JSX do not reset it.
  const givenKey = JSON.stringify([initialTasks, initialHandoffs]);
  const [given, setGiven] = useState(givenKey);
  if (given !== givenKey) {
    setGiven(givenKey);
    setTasks([...initialTasks]);
    setHandoffs([...initialHandoffs]);
    setFresh(new Set());
    setAsking(null);
    setTip(null);
  }

  const byId = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]);
  const running = tasks.filter((task) => task.status === 'running').length;
  const needing = tasks.filter((task) => task.status === 'needs').length;

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

  const setNow = useCallback((now: number) => {
    clock.current.now = now;
    body.current?.style.setProperty('--now', now.toFixed(4));
  }, []);

  // The clock: runs while the board is on screen, faster while the demo plays.
  useEffect(() => {
    if (!visible) return;
    let frame = 0;
    let timer = 0;
    let last = performance.now();
    const tick = () => {
      const time = performance.now();
      setNow(clock.current.now + ((time - last) / 60000) * speedRef.current);
      last = time;
      // The tooltip's minutes: a tenth of a minute at a time while one shows.
      const grain = tipOn.current ? 10 : 1;
      const shownNow = Math.floor(clock.current.now * grain) / grain;
      if (shownNow !== clock.current.shown) {
        clock.current.shown = shownNow;
        if (tipOn.current) setShown(shownNow);
      }
      schedule();
    };
    const schedule = () => {
      const pxPerMs = (speedRef.current * kRef.current) / 60000;
      const every = pxPerMs > 0 ? STEP_PX / pxPerMs : 1000;
      if (every <= 20) frame = requestAnimationFrame(tick);
      else timer = window.setTimeout(tick, Math.min(1000, every));
    };
    schedule();
    return () => {
      cancelAnimationFrame(frame);
      window.clearTimeout(timer);
    };
  }, [visible, demo, setNow]);

  // The lanes' px per minute and each lane's middle, from the layout.
  useLayoutEffect(() => {
    const lanesBox = body.current;
    const ruler = axis.current;
    if (!lanesBox || !ruler) return;
    const measure = () => {
      const k = ruler.clientWidth / span;
      kRef.current = k;
      lanesBox.style.setProperty('--k', `${k}px`);
      const top = lanesBox.getBoundingClientRect().top;
      const mids: Record<string, number> = {};
      lanesBox.querySelectorAll<HTMLElement>('[data-lane]').forEach((lane) => {
        const rect = lane.getBoundingClientRect();
        // In the box's own px, should the board sit in a scaled container.
        const scale = rect.width / lane.offsetWidth || 1;
        mids[lane.dataset.lane!] = (rect.top - top + rect.height / 2) / scale;
      });
      setGeo({ k, mids });
    };
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(lanesBox);
    observer.observe(ruler);
    return () => observer.disconnect();
  }, [span, agents]);

  // The tooltip stays inside the card.
  useLayoutEffect(() => {
    const box = tipBox.current;
    const frame = card.current;
    if (!box || !frame || !tip) return;
    const max = frame.offsetWidth - box.offsetWidth - 8;
    box.style.left = `${Math.max(8, Math.min(tip.x, max))}px`;
    // Under its bar, or over it when the card's bottom is too near.
    const below = tip.y + box.offsetHeight < frame.offsetHeight + 48;
    box.style.top = below ? `${tip.y}px` : `${tip.y - box.offsetHeight - 26 - 8}px`;
  });

  // Where the popover goes: above its bar, its right edge on the bar's (below if no room).
  const [askAt, setAskAt] = useState<{ right: number; top?: number; bottom?: number } | null>(null);
  const placeAsk = useCallback((id: string) => {
    const bar = bars.current.get(id);
    const frame = card.current;
    if (!bar || !frame) return;
    const f = frame.getBoundingClientRect();
    const b = bar.getBoundingClientRect();
    const scale = f.width / frame.offsetWidth || 1;
    const right = Math.max(8, (f.right - b.right) / scale);
    const above = (b.top - f.top) / scale;
    setAskAt(
      above > 120
        ? { right, bottom: (f.bottom - b.top) / scale + 4 }
        : { right, top: (b.bottom - f.top) / scale + 4 },
    );
  }, []);

  const open = (id: string, byReader: boolean) => {
    setTip(null);
    askFocus.current = byReader;
    placeAsk(id);
    setAsking(id);
  };

  // Opened in a given state: the popover of one task, or the tooltip of one, once laid out.
  // Both are placed from their bar's box, so they can only be set once the lanes are laid out.
  useLayoutEffect(() => {
    // eslint-disable-next-line react-hooks/set-state-in-effect
    if (defaultOpenTask) open(defaultOpenTask, false);
    else if (defaultTooltip) showTip(defaultTooltip, 'tap');
    // Only on mount and when the asked task changes, not on every render.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [defaultOpenTask, defaultTooltip]);

  // The popover: the reader's focus goes in, Escape or a press outside closes it.
  useEffect(() => {
    if (!asking) return;
    if (askFocus.current) approveButton.current?.focus();
    const away = (event: PointerEvent) => {
      const target = event.target as Node;
      if (askBox.current?.contains(target) || chips.current.get(asking)?.contains(target)) return;
      setAsking(null);
    };
    document.addEventListener('pointerdown', away);
    return () => document.removeEventListener('pointerdown', away);
  }, [asking]);

  const decide = (id: string, decision: 'approve' | 'reject') => {
    const task = byId.get(id);
    if (!task) return;
    const reader = askFocus.current;
    setAsking(null);
    setTasks((all) =>
      all.map((each) =>
        each.id !== id
          ? each
          : decision === 'approve'
            ? { ...each, status: 'running' }
            : { ...each, status: 'failed', end: clock.current.now },
      ),
    );
    const agent = agents.find((each) => each.id === task.agent)?.name ?? 'The agent';
    announce(
      decision === 'approve'
        ? `${task.label} approved: ${agent} is running it.`
        : `${task.label} rejected: ${agent} stopped it.`,
    );
    onDecision?.(task, decision);
    if (reader) bars.current.get(id)?.focus();
  };

  /** A tooltip for a bar, `x` px from the card's left (or the bar's left, plus a little). */
  const showTip = (id: string, via: Tip['via'], clientX?: number) => {
    const bar = bars.current.get(id);
    const frame = card.current;
    if (!bar || !frame) return;
    const f = frame.getBoundingClientRect();
    const b = bar.getBoundingClientRect();
    const scale = f.width / frame.offsetWidth || 1;
    const from = clientX ?? Math.max(b.left, f.left) + 24;
    setTip({ id, via, x: (from - f.left) / scale - 14, y: (b.bottom - f.top) / scale + 4 });
  };

  // Lanes and their tasks: which tasks sit in which lane.
  const lanes = useMemo(
    () => agents.map((agent) => ({ agent, tasks: tasks.filter((t) => t.agent === agent.id) })),
    [agents, tasks],
  );

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

  const demoStep = useEffectEvent((step: DemoStep) => {
    switch (step.kind) {
      case 'tip':
        if (step.id) showTip(step.id, 'demo', step.clientX);
        else setTip((now) => (now?.via === 'demo' ? null : now));
        break;
      case 'over':
        setDemoOver(step.what);
        break;
      case 'open':
        open(step.id, false);
        break;
      case 'approve':
        setDemoOver(null);
        decide(step.id, 'approve');
        break;
      case 'finish': {
        const now = clock.current.now;
        setTasks((all) => [
          ...all.map((task) =>
            task.id === step.from ? { ...task, status: 'done' as const, end: now } : task,
          ),
          { ...REVIEW_TASK, start: now },
        ]);
        setHandoffs((all) => [...all, { from: step.from, to: REVIEW_TASK.id }]);
        setFresh((all) => new Set(all).add(`${step.from}>${REVIEW_TASK.id}`));
        announce(`Writer finished; Reviewer started ${REVIEW_TASK.label}.`);
        break;
      }
      case 'fade':
        setFading(step.on);
        break;
      case 'reset':
        setTasks([...initialTasks]);
        setHandoffs([...initialHandoffs]);
        setFresh(new Set());
        setAsking(null);
        setTip(null);
        setNow(0);
        break;
    }
  });

  /** Whether the board has moved on from where the demo starts. */
  const dirty = useEffectEvent(
    () =>
      clock.current.now > 0.5 ||
      tasks.length !== initialTasks.length ||
      tasks.some((task, i) => task.status !== initialTasks[i]?.status),
  );

  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 & { cx: number } => {
      const frame = stage.getBoundingClientRect();
      const scale = frame.width / stage.offsetWidth || 1;
      const rect = element?.getBoundingClientRect() ?? frame;
      const cx = rect.left + rect.width * fx;
      return {
        x: (cx - frame.left) / scale,
        y: (rect.top + rect.height * fy - frame.top) / scale,
        cx,
      };
    };
    const barOf = (id: string) => bars.current.get(id);
    /** A visible share along a bar: never under the lanes' faded left edge. */
    const along = (id: string, share: number) => {
      const bar = barOf(id);
      const lane = bar?.closest('[data-lane]');
      if (!bar || !lane) return 0.5;
      const b = bar.getBoundingClientRect();
      const l = lane.getBoundingClientRect();
      const left = Math.max(b.left, l.left + 30);
      const x = left + (Math.min(b.right, l.right) - left) * share;
      return (x - b.left) / (b.width || 1);
    };

    const stop = runDemo(stage, arrow, async ({ wait, glide, glyph }) => {
      const hover = async (id: string, share: number, ms: number, hold: number) => {
        demoStep({ kind: 'tip', id: null });
        glyph('arrow');
        const point = on(barOf(id), along(id, share), 0.55);
        await glide(point, ms);
        glyph('hand');
        demoStep({ kind: 'tip', id, clientX: on(barOf(id), along(id, share), 0.55).cx });
        await wait(hold);
      };
      for (;;) {
        if (dirty()) {
          demoStep({ kind: 'fade', on: true });
          await wait(320);
          demoStep({ kind: 'reset' });
          await wait(60);
          demoStep({ kind: 'fade', on: false });
        }
        await wait(500);
        await hover('draft', 0.32, 560, 950);
        await hover('interviews', 0.62, 480, 1000);
        // The chip, then Approve.
        demoStep({ kind: 'tip', id: null });
        glyph('arrow');
        await glide(on(chips.current.get('keys'), 0.55, 0.6), 600);
        glyph('hand');
        demoStep({ kind: 'over', what: 'chip' });
        await wait(320);
        demoStep({ kind: 'open', id: 'keys' });
        demoStep({ kind: 'over', what: null });
        await wait(520);
        glyph('arrow');
        await glide(on(approveButton.current, 0.45, 0.5), 560);
        glyph('hand');
        demoStep({ kind: 'over', what: 'approve' });
        await wait(380);
        demoStep({ kind: 'approve', id: 'keys' });
        await wait(420);
        await hover('interviews', 0.7, 640, 900);
        // The writer hands its draft over.
        demoStep({ kind: 'tip', id: null });
        glyph('arrow');
        await glide(on(barOf('draft'), 0.5, 2.2), 520);
        await wait(200);
        demoStep({ kind: 'finish', from: 'draft' });
        await wait(500);
        await hover('draft', 0.62, 420, 1000);
        await hover('interviews', 0.5, 600, 1100);
        demoStep({ kind: 'tip', id: null });
        glyph('arrow');
        await glide(on(card.current, 0.8, 1.15), 700);
        await wait(1600);
      }
    });
    return () => {
      stop();
      setDemoOver(null);
      setFading(false);
      setTip((now) => (now?.via === 'demo' ? null : now));
    };
  }, [demo, root]);

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

  const step = tickStep(span);
  const ticks: number[] = [];
  for (let at = step; at < span - step * 0.25; at += step) ticks.push(at);

  const tipTask = tip ? byId.get(tip.id) : undefined;
  const tipAgent = tipTask ? agents.find((agent) => agent.id === tipTask.agent) : undefined;
  const askTask = asking ? byId.get(asking) : undefined;

  const maskBase = `lanes${uid.replace(/[^\w-]/g, '')}`;
  const curves = handoffs.flatMap((handoff) => {
    const from = byId.get(handoff.from);
    const to = byId.get(handoff.to);
    if (!from || !to || from.end === undefined || !geo.k) return [];
    const y1 = geo.mids[from.agent];
    const y2 = geo.mids[to.agent];
    if (y1 === undefined || y2 === undefined) return [];
    const x1 = (from.end + span) * geo.k;
    const x2 = (to.start + span) * geo.k;
    const bend = Math.max(9, Math.min(14, Math.abs(x2 - x1) * 0.6));
    const d = `M${x1.toFixed(1)} ${y1.toFixed(1)}C${(x1 + bend).toFixed(1)} ${y1.toFixed(1)} ${(x2 - bend).toFixed(1)} ${y2.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`;
    const key = `${handoff.from}>${handoff.to}`;
    const mask = `${maskBase}-${key.replace(/[^\w-]/g, '-')}`;
    return [{ key, d, mask, fresh: fresh.has(key) }];
  });

  const onBarEnter = (id: string) => (event: ReactPointerEvent) => {
    if (event.pointerType === 'touch') return;
    showTip(id, 'pointer', event.clientX);
  };
  const onBarLeave = (event: ReactPointerEvent) => {
    if (event.pointerType === 'touch') return;
    setTip((now) => (now?.via === 'pointer' ? null : now));
  };

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="stage"
      data-color-scheme={colorScheme}
      onPointerDownCapture={take}
      onKeyDownCapture={(event) => {
        take();
        if (event.key === 'Escape') {
          if (asking) {
            const id = asking;
            setAsking(null);
            chips.current.get(id)?.focus();
          } else setTip(null);
        }
      }}
      onPointerMove={(event) => {
        // The reader's own pointer on the board takes over from the demo's.
        if (event.pointerType !== 'touch') take();
      }}
    >
      <section
        ref={card}
        className={styles.card}
        data-part="card"
        aria-labelledby={`${uid}-title`}
        style={{ '--window': span } as CSSProperties}
      >
        <header className={styles.header} data-part="header">
          <Sparkle className={styles.sparkle} />
          <h2 id={`${uid}-title`} className={styles.title}>
            {title}
          </h2>
          <p className={styles.counts} data-part="counts">
            {running > 0 && (
              <span className={styles.count} data-tone="running">
                <span className={styles.dot} aria-hidden="true" />
                {running} running
              </span>
            )}
            {needing > 0 && (
              <span className={styles.count} data-tone="needs">
                <span className={styles.dot} aria-hidden="true" />
                {needing} need you
              </span>
            )}
          </p>
          <span className={styles.range} data-part="range">
            Last {span}m
          </span>
        </header>

        <div ref={body} className={styles.body} data-part="lanes" data-fading={fading || undefined}>
          <div className={styles.axisRow} aria-hidden="true">
            <div ref={axis} className={styles.axis} data-part="axis">
              {ticks.map((at) => (
                <span
                  key={at}
                  className={styles.tick}
                  style={{ left: `${((span - at) / span) * 100}%` }}
                >
                  −{at}m
                </span>
              ))}
              <span className={styles.now} data-part="now">
                now
              </span>
            </div>
          </div>

          <ul className={styles.rows} aria-label="Agents and their tasks">
            {lanes.map(({ agent, tasks: own }) => {
              const needs = own.some((task) => task.status === 'needs');
              const idle = !needs && !own.some((task) => task.status === 'running');
              return (
                <li key={agent.id} className={styles.row} data-part="row">
                  <div className={styles.agent} data-part="agent">
                    <span className={styles.tile} data-part="tile">
                      <AgentGlyph mark={agent.mark} className={styles.mark} />
                    </span>
                    <span className={styles.who}>
                      <span className={styles.nameLine}>
                        <span className={styles.name}>{agent.name}</span>
                        {(needs || idle) && (
                          <span
                            key={needs ? 'needs' : 'idle'}
                            className={styles.badge}
                            data-part="badge"
                            data-tone={needs ? 'needs' : 'idle'}
                          >
                            {needs ? 'Needs you' : 'Idle'}
                          </span>
                        )}
                      </span>
                      <span className={styles.model}>{agent.model}</span>
                    </span>
                  </div>
                  <div className={styles.lane} data-lane={agent.id} data-part="lane">
                    {ticks.map((at) => (
                      <span
                        key={at}
                        className={styles.grid}
                        aria-hidden="true"
                        style={{ left: `${((span - at) / span) * 100}%` }}
                      />
                    ))}
                    <ul className={styles.track} aria-label={`${agent.name}’s tasks`}>
                      {own.map((task, index) => (
                        <li
                          key={task.id}
                          className={styles.slot}
                          data-status={task.status}
                          style={
                            {
                              '--start': task.start,
                              '--end': task.end ?? 0,
                              '--delay': `${-index * 0.7}s`,
                            } as CSSProperties
                          }
                        >
                          <button
                            type="button"
                            ref={(element) => {
                              if (element) bars.current.set(task.id, element);
                              else bars.current.delete(task.id);
                            }}
                            className={styles.bar}
                            data-part="bar"
                            data-status={task.status}
                            aria-label={`${task.label}, ${STATUS_WORD[task.status].toLowerCase()}`}
                            aria-describedby={tip?.id === task.id ? `${uid}-tip` : undefined}
                            onPointerEnter={onBarEnter(task.id)}
                            onPointerLeave={onBarLeave}
                            onFocus={(event) => {
                              if (event.currentTarget.matches(':focus-visible'))
                                showTip(task.id, 'focus');
                            }}
                            onBlur={() => setTip((now) => (now?.via === 'focus' ? null : now))}
                            onClick={(event) => {
                              // A tap or a click shows it too, and a second one hides it.
                              if (tip?.id === task.id && tip.via !== 'pointer') setTip(null);
                              else showTip(task.id, 'tap', event.clientX || undefined);
                            }}
                          >
                            <span className={styles.tools} aria-hidden="true">
                              {task.tools.map((tool) => (
                                <ToolGlyph key={tool} tool={tool} className={styles.tool} />
                              ))}
                            </span>
                            <span className={styles.label}>{task.label}</span>
                          </button>
                          {task.status === 'needs' && (
                            <button
                              type="button"
                              ref={(element) => {
                                if (element) chips.current.set(task.id, element);
                                else chips.current.delete(task.id);
                              }}
                              className={styles.chip}
                              data-part="needs-chip"
                              data-over={demoOver === 'chip' || undefined}
                              aria-haspopup="dialog"
                              aria-expanded={asking === task.id}
                              aria-label={`${task.label} needs you: review`}
                              onClick={() =>
                                asking === task.id ? setAsking(null) : open(task.id, true)
                              }
                            >
                              <span
                                className={styles.chipFace}
                                data-part="chip-face"
                                aria-hidden="true"
                              >
                                <Warning className={styles.warning} />
                                Needs you
                              </span>
                              <span className={styles.chipGlow} aria-hidden="true">
                                <Warning className={styles.warning} />
                                Needs you
                              </span>
                            </button>
                          )}
                        </li>
                      ))}
                    </ul>
                  </div>
                </li>
              );
            })}
          </ul>

          <div className={styles.links} aria-hidden="true" data-part="handoffs">
            <svg className={styles.linkTrack} focusable="false">
              {curves.map((curve) => (
                <g key={curve.key}>
                  {curve.fresh && (
                    <mask id={curve.mask} maskUnits="userSpaceOnUse">
                      <path
                        d={curve.d}
                        pathLength={1}
                        className={styles.reveal}
                        fill="none"
                        stroke="#fff"
                        strokeWidth="6"
                      />
                    </mask>
                  )}
                  <path
                    d={curve.d}
                    className={styles.link}
                    mask={curve.fresh ? `url(#${curve.mask})` : undefined}
                  />
                </g>
              ))}
            </svg>
          </div>
        </div>

        {tipTask && tipAgent && tip && (
          <div
            key={tip.id}
            ref={tipBox}
            id={`${uid}-tip`}
            role="tooltip"
            className={styles.tip}
            data-part="tooltip"
          >
            <p className={styles.tipHead}>
              {tipAgent.name} · {STATUS_WORD[tipTask.status]} ·{' '}
              {minutes((tipTask.end ?? shown) - tipTask.start)}
            </p>
            {tipTask.notes && (
              <ul className={styles.notes}>
                {tipTask.notes.map((note) => (
                  <li key={note}>{note}</li>
                ))}
              </ul>
            )}
          </div>
        )}

        {askTask?.ask && askAt && (
          <div
            ref={askBox}
            role="dialog"
            aria-labelledby={`${uid}-ask`}
            aria-describedby={`${uid}-ask-body`}
            className={styles.ask}
            data-part="popover"
            style={{ right: askAt.right, top: askAt.top, bottom: askAt.bottom }}
          >
            <h3 id={`${uid}-ask`} className={styles.askTitle}>
              {askTask.ask.title}
            </h3>
            <p id={`${uid}-ask-body`} className={styles.askBody}>
              {askTask.ask.body}
            </p>
            <div className={styles.actions}>
              <button
                type="button"
                className={styles.reject}
                data-part="reject"
                onClick={() => decide(askTask.id, 'reject')}
              >
                Reject
              </button>
              <button
                ref={approveButton}
                type="button"
                className={styles.approve}
                data-part="approve"
                data-over={demoOver === 'approve' || undefined}
                onClick={() => decide(askTask.id, 'approve')}
              >
                Approve
              </button>
            </div>
          </div>
        )}
      </section>

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

type DemoStep =
  | { kind: 'tip'; id: string | null; clientX?: number }
  | { kind: 'over'; what: 'chip' | 'approve' | null }
  | { kind: 'open'; id: string }
  | { kind: 'approve'; id: string }
  | { kind: 'finish'; from: string }
  | { kind: 'fade'; on: boolean }
  | { kind: 'reset' };
```

### agent-lanes/agent-lanes.module.css

```css
/* Agent lanes (agent-lanes.tsx): a quiet grey stage with the card in its middle, or at night a
   near-black one with the card a step darker still. The card is laid out for 666 px: a 140 px
   column of agents, then lanes on a shared axis that ends at now. Under 560 px of card, each
   agent sits over its lane instead of beside it.

   Time: the script sets --k (px per minute, from the lanes' width) and --now (minutes since
   mount) on .body; --window is the axis's span. A bar sits at (start + window) × k on its lane's
   track, which slides left by now × k; a running bar is (now − start) × k wide, so it ends on now.

   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: #e6e6e6;
  --line: #efefef;
  --grid: rgb(0 0 0 / 0.035);
  --card-shadow: rgb(0 0 0 / 0.03);
  /* Text: names, models and quiet counts, the axis. */
  --ink: #111113;
  --ink-2: #737376;
  --ink-3: #a6a6a8;
  /* The ink-coloured strokes of the tool glyphs and marks. */
  --glyph-ink: #19191b;
  --tile: #fdfdfd;
  --tile-edge: #e6e6e6;
  --tile-shadow: rgb(0 0 0 / 0.06);
  /* The header: its sparkle, the counts' dots and the range pill. */
  --sparkle: #3d7af9;
  --sparkle-dot: #9dbcf7;
  --running-dot: #1a6dd3;
  --needs-dot: #d98d62;
  --needs-count: #c13a06;
  --pill: #ededed;
  --pill-ink: #444446;
  --now-bg: #e4f3fd;
  --now-ink: #156fd1;
  /* The bars, by status. */
  --done: #eeeeee;
  --done-ink: #444446;
  --run: #e1f3fc;
  --run-ink: #1570d3;
  --run-edge: #6f9cee;
  --shine: rgb(255 255 255 / 0.62);
  --fail: #fae5e1;
  --fail-ink: #c8300f;
  --needs: #fbecd1;
  --needs-ink: #b2440b;
  /* The "Needs you" chip, dim and lit, and the badges by a name. */
  --chip: rgb(255 255 255 / 0.5);
  --chip-ink: #dc7d47;
  --chip-lit: #fff;
  --chip-lit-ink: #c4501a;
  --chip-shadow: rgb(120 60 10 / 0.14);
  --idle: #ececec;
  --idle-ink: #454547;
  --needs-badge: #fdebd1;
  --needs-badge-ink: #b8400a;
  /* The handoff curve. */
  --link: #7aa3e6;
  /* The tooltip: dark by day, light at night. */
  --tip: #232326;
  --tip-ink: #ececee;
  --tip-head: #8e8e93;
  --tip-dot: #8a8a8e;
  --tip-shadow: rgb(0 0 0 / 0.14);
  /* The popover and its buttons. */
  --pop: #fff;
  --pop-edge: rgb(0 0 0 / 0.08);
  --pop-ink: #111113;
  --pop-body: #4d4d50;
  --pop-shadow: rgb(0 0 0 / 0.1);
  --reject: #fff;
  --reject-hover: #f4f4f5;
  --reject-edge: #e2e2e4;
  --approve: #2c2a31;
  --approve-hover: #45434b;
  --approve-ink: #fff;
  /* 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;
  place-items: center;
  width: 100%;
  min-height: 430px;
  padding: 56px 16px 72px;
  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(#e6e6e6, #2b2b2e);
    --line: light-dark(#efefef, #262629);
    --grid: light-dark(rgb(0 0 0 / 0.035), rgb(255 255 255 / 0.03));
    --card-shadow: light-dark(rgb(0 0 0 / 0.03), rgb(0 0 0 / 0.3));
    --ink: light-dark(#111113, #ececee);
    --ink-2: light-dark(#737376, #88888c);
    --ink-3: light-dark(#a6a6a8, #69696d);
    --glyph-ink: light-dark(#19191b, #e4e4e6);
    --tile: light-dark(#fdfdfd, #1d1d20);
    --tile-edge: light-dark(#e6e6e6, #2e2e31);
    --tile-shadow: light-dark(rgb(0 0 0 / 0.06), rgb(0 0 0 / 0.4));
    --sparkle: light-dark(#3d7af9, #3a6fe6);
    --sparkle-dot: light-dark(#9dbcf7, #2f4f8c);
    --running-dot: light-dark(#1a6dd3, #61b3ff);
    --needs-dot: light-dark(#d98d62, #e0915f);
    --needs-count: light-dark(#c13a06, #f2925c);
    --pill: light-dark(#ededed, #29292c);
    --pill-ink: light-dark(#444446, #c8c8cb);
    --now-bg: light-dark(#e4f3fd, #162c44);
    --now-ink: light-dark(#156fd1, #519beb);
    --done: light-dark(#eeeeee, #2a2a2d);
    --done-ink: light-dark(#444446, #a3a3a7);
    --run: light-dark(#e1f3fc, #172b45);
    --run-ink: light-dark(#1570d3, #61b0fc);
    --run-edge: light-dark(#6f9cee, #4f86c9);
    --shine: light-dark(rgb(255 255 255 / 0.62), rgb(110 160 240 / 0.16));
    --fail: light-dark(#fae5e1, #3a1b1d);
    --fail-ink: light-dark(#c8300f, #f98169);
    --needs: light-dark(#fbecd1, #3a2815);
    --needs-ink: light-dark(#b2440b, #f0a062);
    --chip: light-dark(rgb(255 255 255 / 0.5), rgb(255 255 255 / 0.05));
    --chip-ink: light-dark(#dc7d47, #c98450);
    --chip-lit: light-dark(#fff, #4a321a);
    --chip-lit-ink: light-dark(#c4501a, #ffb47a);
    --chip-shadow: light-dark(rgb(120 60 10 / 0.14), rgb(0 0 0 / 0.3));
    --idle: light-dark(#ececec, #2a2a2d);
    --idle-ink: light-dark(#454547, #a8a8ab);
    --needs-badge: light-dark(#fdebd1, #3a2815);
    --needs-badge-ink: light-dark(#b8400a, #f0a062);
    --link: light-dark(#7aa3e6, #4f78a6);
    --tip: light-dark(#232326, #e8e8e8);
    --tip-ink: light-dark(#ececee, #1a1a1c);
    --tip-head: light-dark(#8e8e93, #646466);
    --tip-dot: light-dark(#8a8a8e, #8a8a8e);
    --tip-shadow: light-dark(rgb(0 0 0 / 0.14), rgb(0 0 0 / 0.45));
    --pop: light-dark(#fff, #222225);
    --pop-edge: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.08));
    --pop-ink: light-dark(#111113, #ececee);
    --pop-body: light-dark(#4d4d50, #a3a3a8);
    --pop-shadow: light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.5));
    --reject: light-dark(#fff, #2a2a2d);
    --reject-hover: light-dark(#f4f4f5, #333336);
    --reject-edge: light-dark(#e2e2e4, #3a3a3d);
    --approve: light-dark(#2c2a31, #ececee);
    --approve-hover: light-dark(#45434b, #ffffff);
    --approve-ink: light-dark(#fff, #18181a);
  }
}
/* 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, p, ul, li) {
  margin: 0;
  padding: 0;
}
.root :where(ul) {
  list-style: none;
}
.root :where(button) {
  font: inherit;
  color: inherit;
  margin: 0;
}

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

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

.header {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  min-height: 44px;
  padding: 0 15px 0 18px;
  border-bottom: 1px solid var(--line);
}
.sparkle {
  flex: none;
  width: 10.5px;
  height: 10.5px;
  margin: 0 11px 0 0;
}
.title {
  font-size: 12px;
  font-weight: 500;
  line-height: 18px;
}
.counts {
  display: flex;
  flex-wrap: wrap;
  column-gap: 12px;
  margin-left: 10px;
  font-size: 11px;
  line-height: 18px;
}
.count {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  color: var(--ink-2);
  white-space: nowrap;
  animation: appear 0.24s ease-out;
}
.count[data-tone='needs'] {
  color: var(--needs-count);
}
.dot {
  width: 5.5px;
  height: 5.5px;
  border-radius: 50%;
  background: var(--running-dot);
}
.count[data-tone='needs'] .dot {
  background: var(--needs-dot);
}
.range {
  margin-left: auto;
  padding: 0 7.5px;
  border-radius: 5px;
  background: var(--pill);
  color: var(--pill-ink);
  font-size: 11px;
  line-height: 18px;
  white-space: nowrap;
}

/* ---- Axis and lanes ---------------------------------------------------------------------- */

.body {
  --now: 0;
  --k: 16.5px;
  position: relative;
  padding: 0 15px 11px;
  transition: opacity 0.3s ease;
}
.body[data-fading] {
  opacity: 0;
}

.axisRow,
.row {
  display: grid;
  grid-template-columns: 140px minmax(0, 1fr);
}
.axisRow {
  height: 23px;
}
.axis {
  grid-column: 2;
  position: relative;
  font-size: 10px;
  line-height: 13px;
  color: var(--ink-3);
  font-variant-numeric: tabular-nums;
}
.tick {
  position: absolute;
  top: 50%;
  transform: translate(-50%, -50%);
  white-space: nowrap;
}
.now {
  position: absolute;
  top: 50%;
  right: 0;
  transform: translateY(-50%);
  padding: 0 4.75px;
  border-radius: 3.5px;
  background: var(--now-bg);
  color: var(--now-ink);
  font-size: 9px;
  font-weight: 500;
}

.row {
  height: 44px;
}
.agent {
  display: flex;
  align-items: center;
  gap: 8.5px;
  min-width: 0;
}
.tile {
  flex: none;
  display: grid;
  place-items: center;
  width: 22.5px;
  height: 22.5px;
  border: 1px solid var(--tile-edge);
  border-radius: 6px;
  background: var(--tile);
  box-shadow: 0 1px 1.5px var(--tile-shadow);
}
.mark {
  width: 13px;
  height: 13px;
}
.who {
  display: flex;
  flex-direction: column;
  min-width: 0;
}
.nameLine {
  display: flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
}
.name {
  font-size: 12px;
  font-weight: 500;
  line-height: 17px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.model {
  font-size: 11px;
  line-height: 16px;
  color: var(--ink-2);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.badge {
  flex: none;
  padding: 0 4.75px;
  border-radius: 3.5px;
  background: var(--idle);
  color: var(--idle-ink);
  font-size: 10px;
  line-height: 14px;
  animation: appear 0.24s ease-out;
}
.badge[data-tone='needs'] {
  background: var(--needs-badge);
  color: var(--needs-badge-ink);
}

.lane {
  position: relative;
  border-bottom: 1px solid var(--line);
}
/* The faint line under each tick, drawn on every lane so they read as one. */
.grid {
  position: absolute;
  top: 0;
  bottom: -1px;
  width: 1px;
  margin-left: -0.5px;
  background: var(--grid);
}
/* The lane's bars on their track: clipped, and faded where they slide out at the left. */
.track {
  position: absolute;
  inset: 0;
  overflow: hidden;
  overflow: clip;
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 22px);
  mask-image: linear-gradient(90deg, transparent, #000 22px);
}
.slot {
  position: absolute;
  top: 9px;
  left: 0;
  height: 26px;
  width: calc((var(--end) - var(--start)) * var(--k));
  transform: translateX(calc((var(--start) + var(--window) - var(--now)) * var(--k)));
}
.slot[data-status='running'],
.slot[data-status='needs'] {
  width: calc((var(--now) - var(--start)) * var(--k));
}

.bar {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  gap: 6px;
  width: 100%;
  padding: 0 7.5px;
  overflow: hidden;
  border: 0;
  border-right: 2.5px solid transparent;
  border-radius: 5px;
  background: var(--done);
  color: var(--done-ink);
  font-size: 11px;
  font-weight: 500;
  line-height: 16px;
  text-align: left;
  white-space: nowrap;
  cursor: default;
  transition:
    background-color 0.22s ease,
    color 0.22s ease,
    border-color 0.22s ease;
}
.bar[data-status='running'] {
  background: var(--run);
  color: var(--run-ink);
  border-right-color: var(--run-edge);
}
.bar[data-status='failed'] {
  background: var(--fail);
  color: var(--fail-ink);
}
.bar[data-status='needs'] {
  padding-right: 86px;
  background: var(--needs);
  color: var(--needs-ink);
}
/* A running bar's shine: a soft band that sweeps across it, left to right. */
.bar::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(90deg, transparent, var(--shine), transparent) no-repeat;
  background-size: 72px 100%;
  background-position: -72px 0;
  opacity: 0;
  transition: opacity 0.22s ease;
  pointer-events: none;
}
.bar[data-status='running']::after {
  opacity: 1;
  animation: shine 2.6s cubic-bezier(0.45, 0, 0.55, 1) var(--delay, 0s) infinite;
}
@keyframes shine {
  from {
    background-position: -72px 0;
  }
  70%,
  to {
    background-position: calc(100% + 72px) 0;
  }
}
.bar:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.tools {
  display: flex;
  gap: 4px;
  flex: none;
}
.tool {
  display: block;
  width: 11px;
  height: 11px;
}
.label {
  overflow: hidden;
  text-overflow: clip;
}

/* The "Needs you" chip: it breathes, lit and dim, until you look at it. */
.chip {
  position: absolute;
  top: 4.5px;
  right: 4px;
  display: grid;
  height: 17px;
  padding: 0;
  border: 0;
  border-radius: 4.5px;
  background: none;
  font-size: 10px;
  line-height: 17px;
  cursor: pointer;
}
.chipFace,
.chipGlow {
  grid-area: 1 / 1;
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 0 6px 0 6.75px;
  border-radius: inherit;
  white-space: nowrap;
}
.chipFace {
  background: var(--chip);
  color: var(--chip-ink);
}
.chipGlow {
  background: var(--chip-lit);
  color: var(--chip-lit-ink);
  box-shadow:
    0 0 0 0.5px var(--chip-shadow),
    0 1px 2px var(--chip-shadow);
  opacity: 0;
  animation: breathe 0.8s ease-in-out infinite alternate;
}
.chip:hover .chipGlow,
.chip:focus-visible .chipGlow,
.chip[data-over] .chipGlow,
.chip[aria-expanded='true'] .chipGlow {
  opacity: 1;
  animation: none;
}
.chip:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
@keyframes breathe {
  from {
    opacity: 0.15;
  }
  to {
    opacity: 1;
  }
}
.warning {
  width: 8.5px;
  height: 8.5px;
}

/* The handoff curves, on a track of their own over all the lanes. */
.links {
  position: absolute;
  top: 23px;
  bottom: 11px;
  left: calc(15px + 140px);
  right: 15px;
  overflow: hidden;
  overflow: clip;
  pointer-events: none;
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 22px);
  mask-image: linear-gradient(90deg, transparent, #000 22px);
}
.linkTrack {
  position: absolute;
  top: -23px;
  left: 0;
  width: 100%;
  height: calc(100% + 23px);
  overflow: visible;
  transform: translateX(calc(var(--now) * var(--k) * -1));
}
.link {
  fill: none;
  stroke: var(--link);
  stroke-width: 1.1px;
  stroke-dasharray: 2.6 2.2;
  stroke-linecap: round;
}
.reveal {
  stroke-dasharray: 1 1;
  stroke-dashoffset: 1;
  animation: draw 0.6s cubic-bezier(0.3, 0, 0.2, 1) 0.1s forwards;
}
@keyframes draw {
  to {
    stroke-dashoffset: 0;
  }
}

/* ---- Tooltip and popover ----------------------------------------------------------------- */

.tip {
  position: absolute;
  z-index: 3;
  max-width: 240px;
  padding: 7px 10px 6px;
  border-radius: 6px;
  background: var(--tip);
  color: var(--tip-ink);
  box-shadow: 0 6px 16px var(--tip-shadow);
  pointer-events: none;
  animation: appear 0.14s ease-out;
}
.tipHead {
  margin-bottom: 4px;
  color: var(--tip-head);
  font-size: 10px;
  line-height: 14px;
  white-space: nowrap;
}
.notes li {
  position: relative;
  padding-left: 9px;
  font-size: 11px;
  line-height: 16.5px;
}
.notes li::before {
  content: '';
  position: absolute;
  top: 7.25px;
  left: 2px;
  width: 2.5px;
  height: 2.5px;
  border-radius: 50%;
  background: var(--tip-dot);
}

.ask {
  position: absolute;
  z-index: 4;
  width: 257px;
  max-width: calc(100% - 16px);
  padding: 11px 11px 10px;
  border: 1px solid var(--pop-edge);
  border-radius: 10px;
  background: var(--pop);
  color: var(--pop-ink);
  box-shadow:
    0 10px 28px var(--pop-shadow),
    0 1px 3px var(--pop-shadow);
  transform-origin: 100% 100%;
  animation: pop 0.16s cubic-bezier(0.2, 0, 0, 1);
}
.askTitle {
  font-size: 12px;
  font-weight: 500;
  line-height: 16px;
}
.askBody {
  margin: 2.75px 0 8.75px;
  color: var(--pop-body);
  font-size: 11px;
  line-height: 15px;
}
.actions {
  display: flex;
  justify-content: flex-end;
  gap: 5px;
}
.reject,
.approve {
  height: 26px;
  padding: 0 9.5px;
  border-radius: 6px;
  font-size: 12px;
  font-weight: 500;
  cursor: pointer;
  transition: background-color 0.15s ease;
}
.reject {
  border: 1px solid var(--reject-edge);
  background: var(--reject);
  box-shadow: 0 1px 1px rgb(0 0 0 / 0.04);
}
.reject:hover {
  background: var(--reject-hover);
}
.approve {
  border: 0;
  background: var(--approve);
  color: var(--approve-ink);
}
.approve:hover,
.approve[data-over] {
  background: var(--approve-hover);
}
.reject:focus-visible,
.approve:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

@keyframes appear {
  from {
    opacity: 0;
  }
}
@keyframes pop {
  from {
    opacity: 0;
    transform: translateY(3px) scale(0.98);
  }
}

/* ---- Narrow: each agent over its lane ------------------------------------------------------ */

@container (max-width: 559px) {
  .axisRow,
  .row {
    grid-template-columns: minmax(0, 1fr);
  }
  .axis {
    grid-column: 1;
  }
  .row {
    height: auto;
  }
  .agent {
    height: 28px;
    padding-top: 6px;
    gap: 7px;
  }
  .tile {
    width: 20px;
    height: 20px;
    border-radius: 5px;
  }
  .mark {
    width: 11px;
    height: 11px;
  }
  .who {
    flex-direction: row;
    align-items: center;
    gap: 7px;
  }
  .lane {
    height: 38px;
  }
  .slot {
    top: 6px;
  }
  .links {
    left: 15px;
  }
  .header {
    padding-block: 7px;
    row-gap: 2px;
  }
}
@container (max-width: 359px) {
  .model {
    display: none;
  }
  .counts {
    margin-left: 8px;
    column-gap: 8px;
    font-size: 12px;
  }
}

/* ---- 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) {
  .bar,
  .bar::after,
  .body {
    transition: none;
  }
  .bar[data-status='running']::after,
  .chipGlow,
  .count,
  .badge,
  .tip,
  .ask {
    animation: none;
  }
  .chipGlow {
    opacity: 0;
  }
  .reveal {
    animation: none;
    stroke-dashoffset: 0;
  }
}
```

### agent-lanes/data.ts

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

/*
 * The shapes the lanes draw, and the board the lab shows: four agents, their tasks of the last
 * half hour and the one handoff between them. Times are in minutes from the moment the board
 * mounts: -12 is twelve minutes ago.
 */

/** The tools a task used: a small glyph each, drawn in glyphs.tsx. */
export type Tool = 'search' | 'doc' | 'chat' | 'design' | 'code' | 'ticket' | 'key' | 'sync';

/** An agent's mark on its tile: one per model family. */
export type AgentMark = 'petal' | 'rings' | 'diamond' | 'hex';

/**
 * Where a task stands. A running task, or one waiting for you, reaches up to now; a done or
 * failed one has its end.
 */
export type TaskStatus = 'running' | 'needs' | 'done' | 'failed';

export type Agent = {
  id: string;
  name: string;
  /** The model it runs on, under its name. */
  model: string;
  mark: AgentMark;
};

export type Task = {
  id: string;
  /** The agent whose lane it sits in. */
  agent: string;
  label: string;
  tools: readonly Tool[];
  status: TaskStatus;
  /** When it started, in minutes from mount (negative: before it). */
  start: number;
  /** When it ended, for a done or failed task. */
  end?: number;
  /** What it did, in the tooltip. */
  notes?: readonly string[];
  /** For a task that needs you: what it asks, in the popover with Approve and Reject. */
  ask?: { title: string; body: string };
};

/** A task handing its work to another: a dashed curve from the end of one to the start of the other. */
export type Handoff = { from: string; to: string };

export const AGENTS: readonly Agent[] = [
  { id: 'researcher', name: 'Researcher', model: 'Model A large', mark: 'petal' },
  { id: 'writer', name: 'Writer', model: 'Model A medium', mark: 'petal' },
  { id: 'reviewer', name: 'Reviewer', model: 'Model B pro', mark: 'rings' },
  { id: 'ops', name: 'Ops', model: 'Model C fast', mark: 'diamond' },
];

export const TASKS: readonly Task[] = [
  {
    id: 'competitors',
    agent: 'researcher',
    label: 'Competitor pricing',
    tools: ['search', 'doc'],
    status: 'done',
    start: -27.4,
    end: -16.4,
    notes: ['Compared 6 pricing pages', 'Built a feature table'],
  },
  {
    id: 'interviews',
    agent: 'researcher',
    label: 'Customer interviews',
    tools: ['doc', 'chat'],
    status: 'running',
    start: -9.4,
    notes: ['Read 14 interview notes', 'Grouping quotes by theme'],
  },
  {
    id: 'draft',
    agent: 'writer',
    label: 'Draft pricing page',
    tools: ['design', 'doc'],
    status: 'running',
    start: -15.4,
    notes: ['Outlined the page', 'Wrote plan copy and FAQ'],
  },
  {
    id: 'billing',
    agent: 'reviewer',
    label: 'PR #482 billing',
    tools: ['code', 'ticket'],
    status: 'done',
    start: -29.3,
    end: -19.4,
    notes: ['Checked 12 changed files', 'Left 3 comments'],
  },
  {
    id: 'sync',
    agent: 'ops',
    label: 'Sync CRM',
    tools: ['sync'],
    status: 'failed',
    start: -27.4,
    end: -20.9,
    notes: ['Token expired at step 2', 'Stopped after 3 retries'],
  },
  {
    id: 'keys',
    agent: 'ops',
    label: 'Rotate API keys',
    tools: ['key', 'code'],
    status: 'needs',
    start: -12.4,
    notes: ['Found 2 keys due', 'Checked the jobs using them'],
    ask: {
      title: 'Rotate API keys',
      body: 'Rotate the production payment and model keys? Jobs reconnect in ~30 s.',
    },
  },
];

export const HANDOFFS: readonly Handoff[] = [{ from: 'competitors', to: 'draft' }];

/** The task the demo hands the finished draft to. */
export const REVIEW_TASK: Omit<Task, 'start'> = {
  id: 'review',
  agent: 'reviewer',
  label: 'Review pricing page',
  tools: ['design', 'doc'],
  status: 'running',
  notes: ['Reading the draft'],
};
```

### agent-lanes/glyphs.tsx

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

/*
 * The small drawings: a glyph for each tool a task uses, a mark for each agent's tile, the
 * header's sparkle and the warning triangle, drawn on a 16-unit grid and 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 { AgentMark, Tool } from './data';

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

function ToolPath({ tool }: { tool: Tool }) {
  switch (tool) {
    case 'search':
      return (
        <>
          <circle cx="6.8" cy="6.8" r="4.3" fill="none" stroke="#2f7cf6" strokeWidth="2.2" />
          <path d="M10.2 10.2l3.6 3.6" stroke="#18a46b" strokeWidth="2.4" strokeLinecap="round" />
        </>
      );
    case 'doc':
      return (
        <>
          <path
            d="M3.2 2.2h6.6l3.2 3.2v8.4H3.2z"
            fill="none"
            style={INK}
            strokeWidth="1.6"
            strokeLinejoin="round"
          />
          <path d="M9.6 2.4v3.2h3.2" fill="none" style={INK} strokeWidth="1.4" />
          <path d="M5.6 8.6h4.8M5.6 11.2h3.4" style={INK} strokeWidth="1.4" strokeLinecap="round" />
        </>
      );
    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="#8b5cf6"
          />
          <circle cx="5.4" cy="6.7" r="1.05" fill="#fff" />
          <circle cx="8" cy="6.7" r="1.05" fill="#fff" />
          <circle cx="10.6" cy="6.7" r="1.05" fill="#fff" />
        </>
      );
    case 'design':
      return (
        <>
          <circle cx="5" cy="5" r="3.2" fill="#ff5f57" />
          <rect x="8.6" y="1.8" width="5.6" height="6.4" rx="1.6" fill="#8b5cf6" />
          <path d="M4.8 9l3.6 5.4H1.2z" fill="#14b8e8" strokeLinejoin="round" />
          <circle cx="11.4" cy="11.6" r="2.6" fill="#22c55e" />
        </>
      );
    case 'code':
      return (
        <path
          d="M5.4 4.2L1.8 8l3.6 3.8M10.6 4.2L14.2 8l-3.6 3.8M9.1 2.8L6.9 13.2"
          fill="none"
          style={INK}
          strokeWidth="1.8"
          strokeLinecap="round"
          strokeLinejoin="round"
        />
      );
    case 'ticket':
      return (
        <>
          <circle cx="8" cy="8" r="6.6" fill="#5b5bd6" />
          <path
            d="M5.1 8.2l2 2 3.8-4.2"
            fill="none"
            stroke="#fff"
            strokeWidth="1.8"
            strokeLinecap="round"
            strokeLinejoin="round"
          />
        </>
      );
    case 'key':
      return (
        <>
          <rect x="0.8" y="0.8" width="14.4" height="14.4" rx="3.4" fill="#6a4cf0" />
          <circle cx="6" cy="8" r="2.3" fill="none" stroke="#fff" strokeWidth="1.6" />
          <path
            d="M8.2 8h4.4M11.2 8v2"
            stroke="#fff"
            strokeWidth="1.6"
            strokeLinecap="round"
            fill="none"
          />
        </>
      );
    case 'sync':
      return (
        <>
          <path
            d="M13 6.4A5.2 5.2 0 0 0 3.6 4.8M3 9.6a5.2 5.2 0 0 0 9.4 1.6"
            fill="none"
            stroke="#ef5a3c"
            strokeWidth="1.9"
            strokeLinecap="round"
          />
          <path
            d="M3.4 1.6v3.6H7M12.6 14.4v-3.6H9"
            fill="none"
            stroke="#ef5a3c"
            strokeWidth="1.9"
            strokeLinecap="round"
            strokeLinejoin="round"
          />
        </>
      );
  }
}

export function ToolGlyph({ tool, className }: { tool: Tool; className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true" focusable="false">
      <ToolPath tool={tool} />
    </svg>
  );
}

function MarkPath({ mark }: { mark: AgentMark }) {
  switch (mark) {
    case 'petal':
      // 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 'rings':
      return (
        <g fill="none" style={INK} strokeWidth="1.6">
          <circle cx="6" cy="8" r="4.4" />
          <circle cx="10" cy="8" r="4.4" />
        </g>
      );
    case 'diamond':
      return (
        <>
          <path d="M8 1.2l6.8 6.8L8 14.8 1.2 8z" fill="#2f5bff" strokeLinejoin="round" />
          <path d="M8 5.4L10.6 8 8 10.6 5.4 8z" fill="var(--tile)" />
        </>
      );
    case 'hex':
      return <path d="M8 1.4l5.7 3.3v6.6L8 14.6l-5.7-3.3V4.7z" fill="#16a34a" />;
  }
}

export function AgentGlyph({ mark, className }: { mark: AgentMark; className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true" focusable="false">
      <MarkPath mark={mark} />
    </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 Warning({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true" focusable="false">
      <path d="M8 2.2l6.6 11.6H1.4z" fill="currentColor" strokeLinejoin="round" />
    </svg>
  );
}
```

### agent-lanes/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 (agent-lanes.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 lanes themselves. 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>
  );
}
```

### agent-lanes/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 };
}
```