# Version history

> A workflow’s saved versions as dots on a date-by-time timeline, with a tray of version cards to page through and a 30-day range to slide.

A version history panel docked under a workflow canvas. Each dot holds the versions saved on one day at about the same hour: across is the date and down is the time of day. A dot grows with how much changed and turns green when a version went live. Hover or focus a dot and a tray of cards opens above it, one per version, to page through with Back and Next. Under the dots, a 30-day range slides along the months.

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

Category: Data.

## Anatomy

1. **Panel**: Sits on the editor’s canvas, under the workflow: title, period, plot, range and months.
2. **Period**: A native select that keeps all time, the last 60 days or the last 30. Hidden dots also leave the keyboard order.
3. **Dot**: One day’s saves within the hour or so: across is the date, down the time of day (6 AM to 10 PM). A 24 px button around a mark that grows with the edited fields.
4. **Version card**: The version on show, with its date, note, authors and whether it went live. The next card peeks at the edge.
5. **Pager**: Back and Next slide the cards by one in 460 ms; the counter says which of the spot’s versions is on show.
6. **Range**: A slider as wide as its span (30 days by default) over a tick per day. Drag it, or use the arrow keys, Page Up and Page Down.
7. **Step arrows**: Move the range a week back or on. Each is disabled at its end of the timeline.
8. **Current month**: The month under the range’s middle, set apart in the title’s ink.

## When to use

- An editor whose saves pile up for months, where when someone worked matters as much as what changed.
- Bursts of saves: versions made within the hour or so share one dot, and the tray pages through them.
- Spotting what went live: published versions are green dots among the grey ones.
- Picking a version to compare or inspect, with its note, its authors and its time in one card.

## When not to use

- A handful of versions. A plain list, newest first, reads faster than a plot.
- Line-by-line changes. Use a diff view; this only opens one from Compare.
- Undo within a session. Use undo and redo, not saved versions.
- Phones as the main place it lives: the dots are small targets and the tray covers the plot.
- Events with no time of day, such as releases by date. Use a timeline or a calendar.

## Installation

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

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

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

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

## Usage

```tsx
import { VersionHistory } from './version-history';
import { SPOTS } from './versions';

export function WorkflowHistory({ open }: { open: (version: number) => void }) {
  return (
    <VersionHistory
      autoplay={false}
      spots={SPOTS}
      start="2025-09-01"
      days={93}
      onCompare={(version) => open(version.number)}
      onDetails={(version) => open(version.number)}
    />
  );
}
```

## States

- **Closed**: No dot open: the plot alone, the range on September.
- **Dot open**: Hovering, focusing or tapping a dot opens its tray at the newest version.
- **Paged**: Third of five: Back is on, and the cards have slid two along.
- **Oldest version**: The last page of the spot: Next is disabled and nothing peeks at the edge.
- **Published**: A version that went live: its dot is green and so is the mark beside its authors.
- **Range moving**: While the range moves, a band shows it on the plot and the dots outside it fade to 40%.
- **Last 30 days**: Older dots are hidden and skipped by the keys; the range rests at the end, its forward arrow off.
- **Dark**: Every colour is a light-dark() token, so `colorScheme="dark"` or a dark page turns it all.

## How it works

- Each dot is a day and its versions. It sits at its date and at its mean time of day between 6 AM and 10 PM, takes its size from the total number of changed fields, and is green when one of its versions was published.
- The tray shows one card per version, newest first. Only four cards are mounted: the one on show, the one before it and the next two. Each is placed by translateX, its distance from the page on show times a card plus the gap, and the transform is transitioned, so Back and Next slide the cards.
- The tray is placed with the CSS translate property, from the dot’s position and kept inside the panel. It is a layout boundary of a set size, so opening another dot lays out the tray alone, and its clipping viewport is a layer of its own, so cards sliding under it never resize the tray’s layer.
- The range rides a carrier as wide as the track, translated by its first day. A drag writes the fractional day to a CSS variable on each frame without re-rendering, and the state snaps to whole days. The frosted handle shows the track’s ticks softly from edge to edge, offset to stay on them, with a clear margin around its label, and while the range moves a band marks it on the plot and the dots outside it fade back.
- The month under the range’s middle turns bold. The select keeps all versions, or only the last 60 or 30 days. The demo hovers three dots, pages forward and back, then drags the range a month to the right and back.
- Every colour of the window, its panel, plot, tray, cards and scrubber is a light-dark() token, so it follows the page’s color-scheme, or the colorScheme prop; the avatars and the chat tool’s mark keep their colours.

## API reference

- **spots** (`Spot[]`, default `SPOTS`): The dots: each is a day and the versions saved then, oldest first.
- **start** (`string`, default `'2025-09-01'`): The timeline’s first day, as “YYYY-MM-DD”.
- **days** (`number`, default `93`): How many days the timeline shows.
- **span** (`number`, default `30`): How many days the range covers.
- **defaultSpot** (`number`, default `5`): The dot whose tray is open at first, as its index in spots; -1 for none.
- **defaultRange** (`number`, default `30`): The range’s first day at first, counted from start.
- **defaultPage** (`number`, default `0`): The open tray’s card shown at first, newest first (0 is the newest). Given, it turns the demo off.
- **defaultPeriod** (`0 | 30 | 60`, default `0`): The select’s period at first, in days before the end; 0 is All time. Given, it turns the demo off.
- **defaultRanging** (`boolean`, default `false`): Opens with the range drawn on the plot and the dots outside it faded, held until the range moves. Given, it turns the demo off.
- **onCompare** (`(version: Version) => void`): Called with the version whose Compare button is pressed.
- **onDetails** (`(version: Version) => void`): Called with the version whose Details button is pressed.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer hovers dots, pages through versions and drags the range.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root, a stage of its own.

## Accessibility

- The dots are buttons with full labels: the date, how many versions, the latest version and its time, and “published” where it applies. They form one tab stop; the arrow keys, Home and End move between them and open each tray. They carry aria-expanded and aria-controls, and Escape closes the tray and brings focus back to its dot.
- The tray is a labelled group. Only the card on show can be reached, because the others are inert. Back and Next are disabled at the ends, and the reader’s page turns are announced.
- The range is a role="slider" whose value text reads like “Oct 1 to Oct 30”. The arrow keys move it a day, Page Up and Page Down a week, and Home and End to the ends; the ‹ › buttons are labelled and step a week, and the select is labelled “Period”. The reader’s steps with those buttons and changes of period are announced politely.
- Focus rings are visible throughout. Under reduced motion every change is immediate and there is no demo.

## Source

### version-history/version-history.tsx

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

'use client';

import {
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type ChangeEvent,
  type CSSProperties,
  type FocusEvent,
  type KeyboardEvent,
  type PointerEvent,
} from 'react';
import { DemoPointer, runDemo } from './demo-pointer';
import { ChatMark, CheckCircle, GearIcon, ModelMark } from './icons';
import { PEOPLE, SPOTS, type Spot, type Version } from './versions';
import { useAutoplay } from './use-autoplay';
import styles from './version-history.module.css';

/*
 * Version history: a workflow editor's timeline of saved versions, under its canvas.
 *
 * - Each dot is a spot: the versions saved on one day within the hour or so. Across is the date,
 *   down the time of day (6 AM to 10 PM); a dot grows with how much its versions changed, and is
 *   green when one of them went live.
 * - Hovering, focusing or tapping a dot opens a tray above it: a card per version, newest first,
 *   with the next one peeking at the edge. Back and Next slide the cards along.
 * - Under the dots, a range a set number of days wide (30 by default) slides over a tick per
 *   day: drag it, step it with its arrows or use the keyboard. The month under its middle is set
 *   apart in the titles' ink; while it moves, a band shows it on the plot and the dots outside it
 *   fade back. The select above keeps only recent versions.
 * - What moves does it with transforms: the range by a carrier as wide as its track, each card
 *   by how far it is from the page shown, the dots by a scale. The tray, on a layer of its own,
 *   moves only when another dot opens it.
 * - The dots are buttons with one tab stop: the arrow keys go from one to the next, Escape
 *   closes the tray. The range is a slider. What the reader changes is announced.
 *
 * VersionHistory draws it all in its editor window; while it is on screen and untouched, a demo
 * pointer hovers dots, pages through versions and drags the range.
 *
 * Light or dark: every colour of the window, the canvas and the panel is a light-dark() token in
 * the CSS, so the scheme is the page's color-scheme, or the one the colorScheme prop sets.
 */

/** The time of day the timeline shows, in hours. */
const FIRST_HOUR = 6;
const LAST_HOUR = 22;
/** How far the range steps with its arrows, Page Up and Page Down, in days. */
const WEEK = 7;
/** The select's periods, in days before the end of the timeline (0: all of it). */
const PERIODS = [
  { days: 0, label: 'All time' },
  { days: 60, label: '60 days' },
  { days: 30, label: '30 days' },
];

const DAY = 86_400_000;
const utc = (start: string, day: number) => {
  const [year, month, date] = start.split('-').map(Number);
  return new Date(Date.UTC(year, month - 1, date + day));
};
const shortDate = new Intl.DateTimeFormat('en-US', {
  month: 'short',
  day: 'numeric',
  timeZone: 'UTC',
});
const longDate = new Intl.DateTimeFormat('en-US', {
  month: 'long',
  day: 'numeric',
  timeZone: 'UTC',
});
const monthName = new Intl.DateTimeFormat('en-US', { month: 'long', timeZone: 'UTC' });
/** 10:35 AM. */
const clock = (minutes: number) => {
  const hours = Math.floor(minutes / 60);
  return `${hours % 12 || 12}:${String(minutes % 60).padStart(2, '0')} ${hours < 12 ? 'AM' : 'PM'}`;
};
const clamp = (value: number, low = 0, high = 1) => Math.min(high, Math.max(low, value));
const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;

/** Where a spot sits and how it is drawn. */
const placeOf = (spot: Spot, days: number) => {
  const minutes =
    spot.versions.reduce((sum, version) => sum + version.time, 0) / spot.versions.length;
  const changes = spot.versions.reduce((sum, version) => sum + version.changes, 0);
  const published = spot.versions.some((version) => version.published);
  const size = 8 + 9 * clamp((changes - 6) / 30);
  return {
    /** Across, as a share of the timeline. */
    x: clamp((spot.day + minutes / 1440) / days),
    /** Down, as a share of the day shown. */
    y: clamp((minutes / 60 - FIRST_HOUR) / (LAST_HOUR - FIRST_HOUR)),
    size: published ? Math.max(10, size) : size,
    published,
    minutes,
  };
};

/** The months whose middle falls on the timeline, with their first day and their middle. */
const monthsOf = (start: string, days: number) => {
  const first = utc(start, 0);
  const months: { name: string; from: number; to: number; middle: number }[] = [];
  for (let index = 0; index < 24; index += 1) {
    const begin = Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + index, 1);
    const end = Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + index + 1, 1);
    const from = Math.round((begin - first.getTime()) / DAY);
    const to = Math.round((end - first.getTime()) / DAY);
    if (from >= days) break;
    const middle = (from + to) / 2;
    if (middle >= 0 && middle <= days) {
      months.push({ name: monthName.format(new Date(begin)), from, to, middle });
    }
  }
  return months;
};

export function VersionHistory({
  spots = SPOTS,
  start = '2025-09-01',
  days = 93,
  span = 30,
  defaultSpot = 5,
  defaultRange = 30,
  defaultPage,
  defaultPeriod,
  defaultRanging,
  onCompare,
  onDetails,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The dots: each a day and the versions saved then, oldest first. */
  spots?: Spot[];
  /** The timeline's first day, as YYYY-MM-DD. */
  start?: string;
  /** How many days the timeline shows. */
  days?: number;
  /** How many days the range covers. */
  span?: number;
  /** The dot whose tray is open at first, as its place in `spots` (-1: none). */
  defaultSpot?: number;
  /** The range's first day at first, counted from `start`. */
  defaultRange?: number;
  /** The open tray's card shown at first, newest first (0: the newest). Turns the demo off. */
  defaultPage?: number;
  /** The select's period at first, in days before the end (0: all time; 30 or 60). Turns the
   * demo off. */
  defaultPeriod?: 0 | 30 | 60;
  /** Opens with the range drawn on the plot and the dots outside it faded, held until the range
   * moves. Turns the demo off. */
  defaultRanging?: boolean;
  /** Called with the version whose Compare button is pressed. */
  onCompare?: (version: Version) => void;
  /** Called with the version whose Details button is pressed. */
  onDetails?: (version: Version) => void;
  /** While on screen and untouched, a demo pointer hovers dots, pages and drags the range. */
  autoplay?: boolean;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Extra class for the root, a stage of its own. */
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(7000);
  const last = Math.max(0, days - span);
  const [active, setActive] = useState<number | null>(
    defaultSpot >= 0 && defaultSpot < spots.length ? defaultSpot : null,
  );
  const [page, setPage] = useState(() =>
    defaultSpot >= 0 && defaultSpot < spots.length
      ? clamp(Math.round(defaultPage ?? 0), 0, spots[defaultSpot].versions.length - 1)
      : 0,
  );
  const [range, setRange] = useState(() => clamp(Math.round(defaultRange), 0, last));
  const [period, setPeriod] = useState<number>(
    PERIODS.some((entry) => entry.days === defaultPeriod) ? (defaultPeriod ?? 0) : 0,
  );
  const [announcement, setAnnouncement] = useState('');
  /** The range is being moved: the plot shows it, and fades the dots outside it. */
  const [ranging, setRanging] = useState(defaultRanging === true);
  const rangingTimer = useRef(0);
  /** Keyboard focus is on the stage: the demo leaves it alone. */
  const [keyboard, setKeyboard] = useState(false);
  const dots = useRef<(HTMLButtonElement | null)[]>([]);
  const panel = useRef<HTMLElement>(null);
  const tray = useRef<HTMLDivElement>(null);
  const back = useRef<HTMLButtonElement>(null);
  const next = useRef<HTMLButtonElement>(null);
  const track = useRef<HTMLDivElement>(null);
  const handle = useRef<HTMLDivElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const stopDemo = useRef<(() => void) | null>(null);
  /** The latest of each, for the demo and for keys pressed faster than a render. */
  const now = useRef({ active, page, range });
  useLayoutEffect(() => {
    now.current = { active, page, range };
  }, [active, page, range]);
  /** A reader's drag of the range: where it began, and a day's width on the screen and in the
   * track's own px (the gallery draws the demo scaled down). */
  const grab = useRef<{
    id: number;
    x: number;
    from: number;
    perDay: number;
    dayPx: number;
  } | null>(null);
  const trayId = useId();

  const places = spots.map((spot) => placeOf(spot, days));
  const months = monthsOf(start, days);
  /** Shown by the select: on or after this day. */
  const since = period ? days - period : 0;
  const shown = (index: number) => spots[index].day >= since;
  const inRange = (index: number) => spots[index].day >= range && spots[index].day < range + span;
  const open = active !== null && shown(active) ? active : null;
  const versions = open === null ? [] : [...spots[open].versions].reverse();
  const middle = range + span / 2;
  const current = months.find((month) => middle >= month.from && middle < month.to);
  const dateOf = (day: number) => shortDate.format(utc(start, day));
  const rangeText = `${dateOf(range)} to ${dateOf(range + span - 1)}`;

  /** Opens a dot's tray at its newest version (or closes it with null). */
  const show = (index: number | null) => {
    if (index === now.current.active) return;
    now.current.active = index;
    now.current.page = 0;
    setActive(index);
    setPage(0);
  };

  /** Turns the tray's page; the reader's turns are announced. */
  const turn = (delta: number, byReader: boolean) => {
    const { active: spot, page: from } = now.current;
    if (spot === null) return;
    const count = spots[spot].versions.length;
    const to = clamp(from + delta, 0, count - 1);
    if (to === from) return;
    now.current.page = to;
    setPage(to);
    if (byReader) {
      const version = [...spots[spot].versions].reverse()[to];
      setAnnouncement(
        `Version ${version.number}, ${dateOf(spots[spot].day)}, ${clock(version.time)}: ${version.note}`,
      );
    }
  };

  /** Shows the range on the plot for a moment after it moves, or for as long as it is held. */
  const showRange = (held: boolean) => {
    window.clearTimeout(rangingTimer.current);
    setRanging(true);
    if (!held) rangingTimer.current = window.setTimeout(() => setRanging(false), 1100);
  };
  useEffect(() => () => window.clearTimeout(rangingTimer.current), []);

  /** Moves the range to start on `day`; the reader's moves by button are announced. */
  const slide = (day: number, say = false) => {
    const to = clamp(Math.round(day), 0, last);
    now.current.range = to;
    setRange(to);
    if (!grab.current) showRange(false);
    if (say) setAnnouncement(`Range: ${dateOf(to)} to ${dateOf(to + span - 1)}.`);
  };

  /**
   * Draws the range at a fractional day while it is dragged, without a render for each frame:
   * the panel's --drag moves the handle and the band on the plot, and --shift keeps the ticks seen
   * through the handle on the track's ticks (`dayPx` is a day's width in the track's own px).
   * null lets it settle on its day.
   */
  const drawRange = (day: number | null, dayPx = 0) => {
    const element = panel.current;
    if (!element) return;
    if (day === null) {
      element.style.removeProperty('--drag');
      element.style.removeProperty('--shift');
      delete element.dataset.dragging;
    } else {
      const at = clamp(day, 0, last);
      element.style.setProperty('--drag', at.toFixed(3));
      element.style.setProperty('--shift', `${(-(at % 1) * dayPx).toFixed(2)}px`);
      element.dataset.dragging = '';
    }
  };

  // A tray that opens on another dot settles in from just under its place.
  useEffect(() => {
    const element = tray.current;
    if (open === null || !element || reducedMotion()) return;
    const settle = element.animate(
      [
        { opacity: 0, transform: 'translateY(6px) scale(0.97)' },
        { opacity: 1, transform: 'none' },
      ],
      { duration: 240, easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)' },
    );
    return () => settle.cancel();
  }, [open]);

  // The demo: it hovers a few dots, pages through the versions at one of them, drags the range to
  // November and back, then rests on the dot it began with.
  /** Opened in a given state: held there, with no demo. */
  const held =
    defaultPage !== undefined || defaultPeriod !== undefined || defaultRanging !== undefined;
  const demo = autoplay && !held && playing && !keyboard;
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!demo || !stage || !arrow) return;
    const home = clamp(defaultSpot, 0, spots.length - 1);
    const stop = runDemo(stage, arrow, async ({ wait, moveTo, moveToPoint, press, drag }) => {
      await wait(1200);
      for (;;) {
        for (const index of [2, 7, home]) {
          if (!shown(index)) continue;
          await moveTo(dots.current[index], 'hand');
          show(index);
          await wait(index === home ? 900 : 1500);
        }
        await moveTo(next.current, 'hand');
        await press(() => turn(1, false));
        await wait(1200);
        await press(() => turn(1, false));
        await wait(1300);
        await moveTo(back.current, 'hand');
        await press(() => turn(-1, false));
        await wait(450);
        await press(() => turn(-1, false));
        await wait(900);

        // The range, by its middle, a month to the right and back.
        const box = handle.current?.getBoundingClientRect();
        const rail = track.current;
        if (box && rail) {
          await moveTo(handle.current, 'hand');
          const from = now.current.range;
          const to = clamp(from + 30, 0, last);
          const perDay = rail.getBoundingClientRect().width / days;
          const dayPx = rail.offsetWidth / days;
          const middle = { x: box.left + box.width / 2, y: box.top + box.height / 2 };
          await wait(250);
          showRange(true);
          await drag({ x: middle.x + (to - from) * perDay, y: middle.y }, 1500, (share) => {
            const day = from + (to - from) * share;
            drawRange(day, dayPx);
            slide(day);
          });
          drawRange(null);
          await wait(1400);
          await drag(middle, 1300, (share) => {
            const day = to + (from - to) * share;
            drawRange(day, dayPx);
            slide(day);
          });
          drawRange(null);
          showRange(false);
          await wait(700);
        }
        await moveTo(dots.current[home], 'hand');
        show(home);
        await wait(1200);
        const frame = stage.getBoundingClientRect();
        await moveToPoint({
          x: frame.left + frame.width * 0.93,
          y: frame.top + frame.height * 0.3,
        });
        await wait(2600);
      }
    });
    stopDemo.current = () => {
      stop();
      drawRange(null);
    };
    return stopDemo.current;
    // The demo reads the elements and the latest state through refs as it plays.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [demo, root]);

  /** The reader takes over: the demo stops at once and waits until they leave it alone. */
  const readerActs = () => {
    stopDemo.current?.();
    take();
  };

  /** The dots the keys move through: those shown, in time order. */
  const order = spots.map((_, index) => index).filter(shown);
  const stop = open ?? order.find(inRange) ?? order[0];

  const onDotKey = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
    const at = order.indexOf(index);
    const moves: Partial<Record<string, number>> = {
      ArrowRight: at + 1,
      ArrowDown: at + 1,
      ArrowLeft: at - 1,
      ArrowUp: at - 1,
      Home: 0,
      End: order.length - 1,
    };
    const to = moves[event.key];
    if (to === undefined) return;
    event.preventDefault();
    const target = order[clamp(to, 0, order.length - 1)];
    show(target);
    dots.current[target]?.focus();
  };

  const onRangeKey = (event: KeyboardEvent<HTMLDivElement>) => {
    const from = now.current.range;
    const moves: Partial<Record<string, number>> = {
      ArrowRight: from + 1,
      ArrowUp: from + 1,
      ArrowLeft: from - 1,
      ArrowDown: from - 1,
      PageUp: from + WEEK,
      PageDown: from - WEEK,
      Home: 0,
      End: last,
    };
    const to = moves[event.key];
    if (to === undefined) return;
    event.preventDefault();
    slide(to);
  };

  const onRangeDown = (event: PointerEvent<HTMLDivElement>) => {
    const rail = track.current;
    if (event.button !== 0 || !rail) return;
    event.currentTarget.setPointerCapture(event.pointerId);
    grab.current = {
      id: event.pointerId,
      x: event.clientX,
      from: now.current.range,
      perDay: rail.getBoundingClientRect().width / days,
      dayPx: rail.offsetWidth / days,
    };
    showRange(true);
  };
  const onRangeMove = (event: PointerEvent<HTMLDivElement>) => {
    const held = grab.current;
    if (!held || held.id !== event.pointerId) return;
    const day = held.from + (event.clientX - held.x) / held.perDay;
    drawRange(day, held.dayPx);
    slide(day);
  };
  const onRangeUp = (event: PointerEvent<HTMLDivElement>) => {
    if (grab.current?.id !== event.pointerId) return;
    grab.current = null;
    drawRange(null);
    showRange(false);
  };

  const onPeriod = (event: ChangeEvent<HTMLSelectElement>) => {
    const value = Number(event.target.value);
    setPeriod(value);
    const count = spots
      .filter((spot) => spot.day >= (value ? days - value : 0))
      .reduce((sum, spot) => sum + spot.versions.length, 0);
    setAnnouncement(
      `${PERIODS.find((entry) => entry.days === value)?.label ?? ''}: ${count} versions.`,
    );
  };

  const onFocus = (event: FocusEvent<HTMLDivElement>) => {
    if (event.target.matches(':focus-visible')) setKeyboard(true);
  };
  const onBlur = (event: FocusEvent<HTMLDivElement>) => {
    if (!event.currentTarget.contains(event.relatedTarget)) setKeyboard(false);
  };
  const onEscape = (event: KeyboardEvent<HTMLDivElement>) => {
    if (event.key !== 'Escape' || open === null) return;
    const inside = tray.current?.contains(document.activeElement);
    show(null);
    if (inside) dots.current[open]?.focus();
  };

  const spot = open === null ? null : places[open];

  return (
    <div
      ref={root}
      className={`${styles.stage} ${className}`}
      data-part="window"
      data-color-scheme={colorScheme}
      onPointerDownCapture={readerActs}
      onKeyDownCapture={readerActs}
      onKeyDown={onEscape}
      onFocus={onFocus}
      onBlur={onBlur}
    >
      {/* The editor around the panel: its side bar and its canvas, drawings only. */}
      <div className={styles.sidebar} aria-hidden="true">
        <GearIcon className={styles.gear} />
      </div>
      <div className={styles.canvas} aria-hidden="true">
        <svg className={styles.wire} viewBox="0 0 183 120">
          <path d="M1 -20V86.2q0 17.7 17.7 17.7H183" />
        </svg>
        <span className={styles.port} />
        <div className={`${styles.node} ${styles.stepNode}`}>
          <p className={styles.nodeTitle}>
            <ModelMark className={styles.nodeIcon} />
            Summarise request
          </p>
          <p className={styles.nodeBody}>
            Reads each new request and pulls out the account-related data. The summarised task
            details will go on to the next steps.
          </p>
          <span className={styles.chip}>
            <ModelMark className={styles.chipMark} />
            Kestrel
          </span>
        </div>
        <span className={styles.tag}>
          <CheckCircle className={styles.tagIcon} />
          Output
        </span>
        <div className={`${styles.node} ${styles.outputNode}`}>
          <p className={styles.nodeTitle}>Send Message to #testing channel</p>
          <p className={styles.nodeBody}>
            Posts the formatted summary to the #testing channel as a single new message thread.
          </p>
          <span className={styles.chip}>
            <ChatMark className={styles.chipMark} />
            Relay
          </span>
        </div>
      </div>

      <section
        ref={panel}
        className={styles.panel}
        data-part="panel"
        aria-labelledby={`${trayId}-title`}
        data-ranging={ranging || undefined}
        style={{ '--days': days, '--span': span, '--range': range } as CSSProperties}
      >
        <header className={styles.head}>
          <h3 id={`${trayId}-title`} className={styles.title}>
            Version History
          </h3>
          <select
            className={styles.period}
            data-part="period"
            aria-label="Period"
            value={period}
            onChange={onPeriod}
          >
            {PERIODS.map((entry) => (
              <option key={entry.days} value={entry.days}>
                {entry.label}
              </option>
            ))}
          </select>
        </header>

        <div className={styles.plot} data-part="plot">
          <div className={styles.hours} data-part="hours" aria-hidden="true">
            {[0, 0.5, 1].map((share) => {
              const hour = FIRST_HOUR + share * (LAST_HOUR - FIRST_HOUR);
              return (
                <span key={share} style={{ '--y': share } as CSSProperties}>
                  {clock(hour * 60).replace(':00', '')}
                </span>
              );
            })}
          </div>
          <div
            className={styles.field}
            data-part="field"
            role="group"
            aria-label="Versions by date and time of day"
          >
            <div className={styles.carrier} aria-hidden="true">
              <span className={styles.band} data-part="band" />
            </div>
            {spots.map((entry, index) => {
              const place = places[index];
              const count = entry.versions.length;
              const newest = entry.versions[count - 1];
              return (
                <button
                  key={`${entry.day}-${newest.number}`}
                  ref={(element) => {
                    dots.current[index] = element;
                  }}
                  type="button"
                  className={styles.dot}
                  data-part="dot"
                  style={{ '--x': place.x, '--y': place.y, '--size': place.size } as CSSProperties}
                  data-open={open === index || undefined}
                  data-published={place.published || undefined}
                  data-faded={!inRange(index) || undefined}
                  data-hidden={!shown(index) || undefined}
                  disabled={!shown(index)}
                  tabIndex={index === stop ? 0 : -1}
                  aria-label={`${longDate.format(utc(start, entry.day))}: ${count} ${
                    count === 1 ? 'version' : 'versions'
                  }, the latest Version ${newest.number} at ${clock(newest.time)}${
                    place.published ? ', published' : ''
                  }`}
                  aria-expanded={open === index}
                  aria-controls={trayId}
                  onPointerEnter={(event) => {
                    if (event.pointerType === 'mouse') show(index);
                  }}
                  onClick={() => show(index)}
                  onFocus={() => show(index)}
                  onKeyDown={(event) => onDotKey(event, index)}
                >
                  <span className={styles.mark} data-part="mark" />
                </button>
              );
            })}
          </div>
        </div>

        <div
          ref={tray}
          id={trayId}
          className={styles.tray}
          data-part="tray"
          role="group"
          aria-label={
            open === null
              ? 'Versions'
              : `Versions saved on ${longDate.format(utc(start, spots[open].day))}`
          }
          hidden={open === null}
          style={
            {
              '--x': spot?.x ?? 0,
              '--y': spot?.y ?? 0,
            } as CSSProperties
          }
        >
          {/* The cards around the page shown, each placed by how far it is from it: the one on
              show, the one before it (out of view, to the left), and the next two (the first
              peeking at the edge). Turning the page slides each one over by a card. */}
          <div className={styles.viewport}>
            {open !== null &&
              versions.map((version, index) => {
                const offset = index - page;
                if (offset < -1 || offset > 2) return null;
                return (
                  <article
                    key={version.number}
                    className={styles.card}
                    data-part="card"
                    style={{ '--offset': offset } as CSSProperties}
                    inert={offset !== 0}
                  >
                    <div className={styles.cardHead}>
                      <h4 className={styles.cardTitle}>Version {version.number}</h4>
                      <span className={styles.cardDate}>
                        {dateOf(spots[open].day)}, {clock(version.time)}
                      </span>
                    </div>
                    <p className={styles.note}>{version.note}</p>
                    <div className={styles.people} data-part="people">
                      <span className={styles.faces}>
                        {version.authors.map((person) => (
                          <span
                            key={person}
                            className={styles.face}
                            data-part="face"
                            data-person={person}
                            role="img"
                            aria-label={PEOPLE[person]}
                          />
                        ))}
                      </span>
                      <span
                        className={styles.state}
                        data-part="status"
                        data-published={version.published || undefined}
                        role="img"
                        aria-label={version.published ? 'Published' : 'Saved'}
                      />
                    </div>
                    <div className={styles.actions} data-part="actions">
                      <button type="button" onClick={() => onCompare?.(version)}>
                        Compare
                      </button>
                      <button type="button" onClick={() => onDetails?.(version)}>
                        Details
                      </button>
                    </div>
                  </article>
                );
              })}
          </div>
          <div className={styles.pager} data-part="pager">
            <button
              ref={back}
              type="button"
              className={styles.back}
              disabled={page === 0}
              onClick={() => turn(-1, true)}
            >
              Back
            </button>
            <span className={styles.count} data-part="count" aria-hidden="true">
              <b>{page + 1}</b>/{versions.length}
            </span>
            <button
              ref={next}
              type="button"
              className={styles.next}
              disabled={page >= versions.length - 1}
              onClick={() => turn(1, true)}
            >
              Next
            </button>
          </div>
        </div>

        <div className={styles.scrubber}>
          <button
            type="button"
            className={styles.step}
            data-part="step"
            aria-label={`Move the range back ${WEEK} days`}
            disabled={range === 0}
            onClick={() => slide(range - WEEK, true)}
          />
          <div ref={track} className={styles.track} data-part="track">
            <span className={styles.ticks} aria-hidden="true" />
            <div className={styles.carrier}>
              <div
                ref={handle}
                className={styles.range}
                data-part="range"
                role="slider"
                tabIndex={0}
                aria-label={`${span}-day range`}
                aria-valuemin={0}
                aria-valuemax={last}
                aria-valuenow={range}
                aria-valuetext={rangeText}
                onKeyDown={onRangeKey}
                onPointerDown={onRangeDown}
                onPointerMove={onRangeMove}
                onPointerUp={onRangeUp}
                onPointerCancel={onRangeUp}
              >
                <span className={styles.ghost} aria-hidden="true" />
                <span className={styles.rangeLabel}>{span} Days</span>
              </div>
            </div>
          </div>
          <button
            type="button"
            className={`${styles.step} ${styles.forward}`}
            data-part="step"
            aria-label={`Move the range on ${WEEK} days`}
            disabled={range === last}
            onClick={() => slide(range + WEEK, true)}
          />
        </div>
        <div className={styles.months} data-part="months" aria-hidden="true">
          {months.map((month) => (
            <span
              key={month.name}
              style={{ '--x': month.middle / days } as CSSProperties}
              data-current={month === current || undefined}
            >
              {month.name}
            </span>
          ))}
        </div>
      </section>

      <p className={styles.hidden} aria-live="polite">
        {announcement}
      </p>
      <div className={styles.pointerLayer}>
        <DemoPointer ref={pointer} className={styles.pointer} />
      </div>
    </div>
  );
}
```

### version-history/version-history.module.css

```css
/* Version history (version-history.tsx): an editor window, its side bar, its canvas, and the
   Version History panel docked at the bottom of the canvas. Sizes are in px for a window 830 wide
   and 600 tall; narrower than 600, the side bar goes and the timeline packs tighter.

   Memory. The window is the one layout boundary, of a fixed height. A phone keeps the page's
   pixels in tiles the width of the screen, and a tile painted again gets a second buffer, so what
   changes while the demo plays paints on small layers of its own: the tray, the dots' field, the
   range's row, the months and the line for screen readers. What moves does it with transforms on
   boxes of a fixed size, and only the cards next to the one on show exist, so a page turn moves
   a few cards rather than a strip of all of them. The drawings are SVGs that never change; the
   avatars and the timeline's marks are HTML boxes.

   Colours are the tokens below, light, then light-dark(light, dark) pairs: 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). The pairs sit in @supports, which a build that
   rewrites light-dark() for older browsers leaves as written; without light-dark(), the light
   colours stay. The avatars and the chat tool's mark keep their colours. */

.stage {
  /* The window and its edge; the side bar's gear. */
  --window: #fff;
  --window-edge: rgb(0 0 0 / 6%);
  --gear: #8f8f8f;
  /* The canvas, its edge, its dotted guides, the wire and its port. */
  --canvas: #f5f5f5;
  --line: #e6e6e6;
  --guide-dots: #dddddd;
  --wire: #dcdcdc;
  --port: #fafafa;
  --port-edge: #d6d6d6;
  /* A node, its edge, its lift (near and far) and its icon; the box with what it does, and its
     edge; a chip (and the period's select), its edge, its words and its mark. */
  --node: #f7f7f7;
  --node-edge: #e2e2e2;
  --node-near: rgb(0 0 0 / 4%);
  --node-far: rgb(0 0 0 / 3%);
  --node-icon: #5d5d5d;
  --body: #fff;
  --body-edge: #e6e6e6;
  --control: #fff;
  --control-edge: #e3e3e3;
  --control-shade: rgb(0 0 0 / 5%);
  --chip-ink: #151515;
  --chip-mark: #2b2b2b;
  --select-ink: #222;
  /* The Output tag and its words. */
  --tag: #d3eee2;
  --tag-ink: #3f7a62;
  /* The panel, its edge and its lift; the plot, its top line and its dashed guides; the band
     the range casts on the plot, and its edges. */
  --surface: #fff;
  --panel-edge: #e4e4e4;
  --panel-near: rgb(0 0 0 / 4%);
  --panel-far: rgb(0 0 0 / 4%);
  --plot: #f7f7f7;
  --plot-edge: #f0f0f0;
  --guide: #e2e2e2;
  --band: rgb(0 0 0 / 3.5%);
  --band-edge: rgb(0 0 0 / 5%);
  /* A dot; a published one (and a published version's disc); the open one, its ring and shade. */
  --mark: #d0d0d0;
  --green: #468d73;
  --mark-open: #fff;
  --mark-ring: #c9c9c9;
  --mark-shade: rgb(0 0 0 / 8%);
  /* The tray, its edge and its lift (far and near); a card, its edge and shade, and the rule over
     its buttons; a saved version's disc, and the check in it. */
  --tray: #f7f7f7;
  --tray-edge: #e5e5e5;
  --tray-far: rgb(0 0 0 / 15%);
  --tray-near: rgb(0 0 0 / 4%);
  --surface-2: #fff;
  --card-edge: #e8e8e8;
  --card-shade: rgb(0 0 0 / 4%);
  --rule: #efefef;
  --saved: #cecece;
  --state-ink: #fff;
  /* Compare and Details: their fill, edge, shade, words, and fill under the pointer. */
  --button: #fff;
  --button-edge: #e6e6e6;
  --button-shade: rgb(0 0 0 / 6%);
  --button-ink: #111;
  --button-hover: #fafafa;
  /* The range's handle, its edge, its lift (far and near), its words and the soft ticks seen
     through it. */
  --handle: #fff;
  --handle-edge: #e9e9e9;
  --handle-far: rgb(0 0 0 / 6%);
  --handle-near: rgb(0 0 0 / 4%);
  --handle-ink: #1b1b1b;
  --frost: rgb(0 0 0 / 8%);
  /* The words: titles; dates and what a node does; the hours; a version's note and its title. */
  --ink: #161616;
  --ink-muted: #7b7b7b;
  --ink-faint: #b4b4b4;
  --note: #7e7e7e;
  --card-ink: #181818;
  /* Back and Next, and off; the page count and its page; the range's arrows, and off; the
     ticks; the months, and the one under the range. */
  --pager: #7f7f7f;
  --pager-off: #b9b9b9;
  --count: #c1c1c1;
  --count-now: #8d8d8d;
  --step: #9b9b9b;
  --step-off: #d6d6d6;
  --tick: #d4d4d4;
  --month: #848484;
  --month-now: #151515;
  --focus: #3b82f6;
  /* The demo's pointer looks like the system's on both: a dark arrow with a white edge, a white
     hand outlined in dark. */
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  position: relative;
  height: 600px;
  contain: strict;
  container-type: inline-size;
  border-radius: 18px;
  background: var(--window);
  box-shadow: inset 0 0 0 1px var(--window-edge);
  color: var(--ink);
  font:
    400 14px/1.3 Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .stage {
    --window: light-dark(#fff, #18181a);
    --window-edge: light-dark(rgb(0 0 0 / 6%), rgb(255 255 255 / 6%));
    --gear: light-dark(#8f8f8f, #8a8a8f);
    --canvas: light-dark(#f5f5f5, #111113);
    --line: light-dark(#e6e6e6, #2c2c2f);
    --guide-dots: light-dark(#dddddd, #2a2a2d);
    --wire: light-dark(#dcdcdc, #3a3a3e);
    --port: light-dark(#fafafa, #1c1c1f);
    --port-edge: light-dark(#d6d6d6, #4a4a4e);
    --node: light-dark(#f7f7f7, #1c1c1f);
    --node-edge: light-dark(#e2e2e2, #2e2e31);
    --node-near: light-dark(rgb(0 0 0 / 4%), rgb(0 0 0 / 30%));
    --node-far: light-dark(rgb(0 0 0 / 3%), rgb(0 0 0 / 25%));
    --node-icon: light-dark(#5d5d5d, #a5a5aa);
    --body: light-dark(#fff, #252528);
    --body-edge: light-dark(#e6e6e6, #333336);
    --control: light-dark(#fff, #29292c);
    --control-edge: light-dark(#e3e3e3, #3a3a3d);
    --control-shade: light-dark(rgb(0 0 0 / 5%), rgb(0 0 0 / 30%));
    --chip-ink: light-dark(#151515, #ececee);
    --chip-mark: light-dark(#2b2b2b, #d0d0d4);
    --select-ink: light-dark(#222, #e6e6e8);
    --tag: light-dark(#d3eee2, #1d3a2e);
    --tag-ink: light-dark(#3f7a62, #7cc9a6);
    --surface: light-dark(#fff, #1c1c1f);
    --panel-edge: light-dark(#e4e4e4, #2e2e31);
    --panel-near: light-dark(rgb(0 0 0 / 4%), rgb(0 0 0 / 30%));
    --panel-far: light-dark(rgb(0 0 0 / 4%), rgb(0 0 0 / 35%));
    --plot: light-dark(#f7f7f7, #161618);
    --plot-edge: light-dark(#f0f0f0, #232326);
    --guide: light-dark(#e2e2e2, #2c2c30);
    --band: light-dark(rgb(0 0 0 / 3.5%), rgb(255 255 255 / 3.5%));
    --band-edge: light-dark(rgb(0 0 0 / 5%), rgb(255 255 255 / 6%));
    --mark: light-dark(#d0d0d0, #66666c);
    --green: light-dark(#468d73, #489b7c);
    --mark-open: light-dark(#fff, #2b2b2e);
    --mark-ring: light-dark(#c9c9c9, #8e8e93);
    --mark-shade: light-dark(rgb(0 0 0 / 8%), rgb(0 0 0 / 40%));
    --tray: light-dark(#f7f7f7, #222225);
    --tray-edge: light-dark(#e5e5e5, #333336);
    --tray-far: light-dark(rgb(0 0 0 / 15%), rgb(0 0 0 / 55%));
    --tray-near: light-dark(rgb(0 0 0 / 4%), rgb(0 0 0 / 30%));
    --surface-2: light-dark(#fff, #2b2b2e);
    --card-edge: light-dark(#e8e8e8, #38383b);
    --card-shade: light-dark(rgb(0 0 0 / 4%), rgb(0 0 0 / 30%));
    --rule: light-dark(#efefef, #3a3a3d);
    --saved: light-dark(#cecece, #5c5c61);
    --button: light-dark(#fff, #353539);
    --button-edge: light-dark(#e6e6e6, #424246);
    --button-shade: light-dark(rgb(0 0 0 / 6%), rgb(0 0 0 / 30%));
    --button-ink: light-dark(#111, #ededef);
    --button-hover: light-dark(#fafafa, #3c3c40);
    --handle: light-dark(#fff, #2b2b2e);
    --handle-edge: light-dark(#e9e9e9, #3c3c40);
    --handle-far: light-dark(rgb(0 0 0 / 6%), rgb(0 0 0 / 40%));
    --handle-near: light-dark(rgb(0 0 0 / 4%), rgb(0 0 0 / 30%));
    --handle-ink: light-dark(#1b1b1b, #ececee);
    --frost: light-dark(rgb(0 0 0 / 8%), rgb(255 255 255 / 9%));
    --ink: light-dark(#161616, #ececee);
    --ink-muted: light-dark(#7b7b7b, #9c9ca1);
    --ink-faint: light-dark(#b4b4b4, #86868b);
    --note: light-dark(#7e7e7e, #a0a0a5);
    --card-ink: light-dark(#181818, #ececee);
    --pager: light-dark(#7f7f7f, #a0a0a5);
    --pager-off: light-dark(#b9b9b9, #5c5c60);
    --count: light-dark(#c1c1c1, #8a8a8f);
    --count-now: light-dark(#8d8d8d, #c4c4c8);
    --step: light-dark(#9b9b9b, #a0a0a5);
    --step-off: light-dark(#d6d6d6, #48484c);
    --tick: light-dark(#d4d4d4, #3e3e42);
    --month: light-dark(#848484, #8a8a8f);
    --month-now: light-dark(#151515, #ececee);
    --focus: light-dark(#3b82f6, #5b9bff);
  }
}
/* The colorScheme prop. Without it the root sets nothing and inherits the page's color-scheme. */
.stage[data-color-scheme='light'] {
  color-scheme: light;
}
.stage[data-color-scheme='dark'] {
  color-scheme: dark;
}
.stage[data-color-scheme='system'] {
  color-scheme: light dark;
}
.stage *,
.stage *::before,
.stage *::after {
  box-sizing: border-box;
}
.stage :where(button, select) {
  margin: 0;
  font: inherit;
  color: inherit;
}
.stage :where(h3, h4, p) {
  margin: 0;
}

/* The side bar, with its settings gear at the bottom. */
.sidebar {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 55px;
}
.gear {
  position: absolute;
  bottom: 19px;
  left: 50%;
  width: 19px;
  height: 19px;
  margin-left: -9.5px;
  color: var(--gear);
}

/* The canvas: a field a step off the window, crossed by dotted guides, which fade out to the
   right. */
.canvas {
  position: absolute;
  top: 0;
  right: 9px;
  bottom: 9px;
  left: 55px;
  overflow: hidden;
  border: 1px solid var(--line);
  border-top: 0;
  border-radius: 0 0 12px 12px;
  background: var(--canvas);
}
.canvas::before {
  position: absolute;
  inset: 0;
  /* The guides fade out to the right under a wash of the canvas' own colour. */
  background-image:
    linear-gradient(90deg, transparent 35%, var(--canvas) 70%),
    radial-gradient(circle, var(--guide-dots) 0.75px, transparent 1.05px);
  background-position: 0 0;
  background-size:
    100% 100%,
    60px 6px;
  content: '';
}
.wire {
  position: absolute;
  top: -6px;
  left: 435.7px;
  width: 183px;
  height: 120px;
  overflow: visible;
  fill: none;
  stroke: var(--wire);
  stroke-width: 1.5;
}
.port {
  position: absolute;
  top: 92.4px;
  left: 618.5px;
  width: 11px;
  height: 11px;
  border: 1px solid var(--port-edge);
  border-radius: 50%;
  background: var(--port);
}

/* A node: its title, a box with what it does, and the tool it uses. */
.node {
  position: absolute;
  display: flex;
  flex-direction: column;
  height: 168.3px;
  padding: 0 3.4px;
  border: 1px solid var(--node-edge);
  border-radius: 12px;
  background: var(--node);
  box-shadow:
    0 1px 2px var(--node-near),
    0 4px 12px var(--node-far);
}
.stepNode {
  top: -76px;
  left: 42px;
  width: 333px;
}
.outputNode {
  top: 65.7px;
  left: 639px;
  width: 292px;
}
.nodeTitle {
  display: flex;
  flex: none;
  align-items: flex-start;
  gap: 7px;
  height: 38.9px;
  padding: 10.9px 11px 0;
  overflow: hidden;
  font-size: 15.5px;
  font-weight: 500;
  line-height: 20px;
  white-space: nowrap;
}
.nodeIcon {
  flex: none;
  width: 16px;
  height: 16px;
  margin-top: 2px;
  color: var(--node-icon);
}
.nodeBody {
  flex: none;
  height: 86.3px;
  padding: 8.9px 10.6px 0;
  overflow: hidden;
  border: 1px solid var(--body-edge);
  border-radius: 9px;
  background: var(--body);
  color: var(--ink-muted);
  font-size: 15px;
  line-height: 22.2px;
}
.chip {
  display: inline-flex;
  align-items: center;
  align-self: flex-start;
  gap: 6px;
  height: 24.5px;
  margin: auto 0 auto 4.5px;
  padding: 0 8px 0 7.3px;
  border: 1px solid var(--control-edge);
  border-radius: 6px;
  background: var(--control);
  font-size: 13px;
  font-weight: 500;
  color: var(--chip-ink);
}
.chipMark {
  width: 14px;
  height: 14px;
  color: var(--chip-mark);
}
.tag {
  position: absolute;
  top: 37px;
  left: 639px;
  display: inline-flex;
  align-items: center;
  gap: 4.4px;
  height: 18.8px;
  padding: 0 6.6px 0 6.1px;
  border-radius: 6px;
  background: var(--tag);
  color: var(--tag-ink);
  font-size: 13.4px;
  line-height: 1;
}
.tagIcon {
  width: 13px;
  height: 13px;
}

/* The panel: its head, the plot of dots, the range and the months. */
.panel {
  --gutter-l: 50px;
  --gutter-r: 41px;
  --tray-w: 367px;
  --card-w: 278px;
  /* The panel's inner width: the window's, less the room on its sides and its border. */
  --panel-w: calc(100cqw - 84px);
  position: absolute;
  right: 18px;
  bottom: 17.5px;
  left: 64px;
  height: 296px;
  border: 1px solid var(--panel-edge);
  border-radius: 12px;
  background: var(--surface);
  box-shadow:
    0 1px 2px var(--panel-near),
    0 6px 16px var(--panel-far);
}
.head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  height: 48px;
  padding: 0 12px 0 16px;
}
.title {
  font-size: 15.2px;
  font-weight: 500;
  line-height: 20px;
  color: var(--ink);
}
.period {
  height: 27.4px;
  padding: 0 10px;
  border: 1px solid var(--control-edge);
  border-radius: 7px;
  background: var(--control);
  box-shadow: 0 1px 1px var(--control-shade);
  font-size: 12.7px;
  color: var(--select-ink);
  text-align: center;
  appearance: none;
  cursor: pointer;
}
.period:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.plot {
  position: relative;
  height: 167px;
  background: var(--plot);
  box-shadow: inset 0 1px 0 var(--plot-edge);
}
/* Its guides: a dashed line at 6 AM, 2 PM and 10 PM, and one down its left edge. */
.plot::before {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: var(--gutter-l);
  background:
    linear-gradient(90deg, var(--guide) 50%, transparent 0) 0 21.5px / 8px 1px repeat-x,
    linear-gradient(90deg, var(--guide) 50%, transparent 0) 0 83px / 8px 1px repeat-x,
    linear-gradient(90deg, var(--guide) 50%, transparent 0) 0 144.5px / 8px 1px repeat-x,
    linear-gradient(var(--guide) 50%, transparent 0) 0 0 / 1px 8px repeat-y;
  content: '';
}
.hours span {
  position: absolute;
  top: calc(22px + var(--y) * 123px);
  left: 12px;
  font-size: 10.5px;
  line-height: 12px;
  color: var(--ink-faint);
  white-space: nowrap;
  transform: translateY(-50%);
}

/* The dots: buttons 24 px across, each around a mark drawn 18 px wide and scaled to its size. */
.field {
  position: absolute;
  top: 22px;
  right: var(--gutter-r);
  left: var(--gutter-l);
  height: 123px;
  will-change: transform;
}
.dot {
  position: absolute;
  top: calc(var(--y) * 100%);
  left: calc(var(--x) * 100%);
  width: 24px;
  height: 24px;
  margin: -12px 0 0 -12px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: none;
  cursor: pointer;
  transition: opacity 0.3s ease;
}
.mark {
  position: absolute;
  inset: 3px;
  border-radius: 50%;
  background: var(--mark);
  transform: scale(calc(var(--size) / 18));
  transition:
    transform 0.28s cubic-bezier(0.3, 1.4, 0.5, 1),
    background-color 0.2s ease,
    box-shadow 0.2s ease;
}
.dot[data-published] .mark {
  background: var(--green);
}
.dot[data-open] .mark {
  background: var(--mark-open);
  box-shadow:
    inset 0 0 0 1.5px var(--mark-ring),
    0 1px 3px var(--mark-shade);
  transform: scale(0.95);
}
/* While the range moves, the dots outside it fade back. */
.panel[data-ranging] .dot[data-faded]:not([data-open]) {
  opacity: 0.4;
}
.dot[data-hidden] {
  opacity: 0;
  pointer-events: none;
}
.dot:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 0;
}
@media (hover: hover) {
  .dot:not([data-open]):hover .mark {
    transform: scale(calc(var(--size) / 18 + 0.2));
  }
}

/* The tray, its bottom 22 px above its dot's middle, kept inside the panel. It is placed by a
   translation, and it is a layout boundary of a set size: opening it on another dot, or turning
   its page, lays out the tray alone and moves a layer, never the window around it. */
.tray {
  position: absolute;
  z-index: 3;
  top: 0;
  left: 0;
  width: var(--tray-w);
  height: 222.1px;
  contain: size layout style;
  border: 1px solid var(--tray-edge);
  border-radius: 14px;
  background: var(--tray);
  box-shadow:
    0 10px 22px -8px var(--tray-far),
    0 2px 6px var(--tray-near);
  translate: clamp(
      8px,
      calc(
        var(--gutter-l) + var(--x) * (var(--panel-w) - var(--gutter-l) - var(--gutter-r)) -
          var(--tray-w) / 2
      ),
      calc(var(--panel-w) - var(--tray-w) - 8px)
    )
    calc(48px + var(--y) * 123px - 100%);
  transform-origin: 50% 100%;
  /* A layer of its own: what changes in it repaints it alone. */
  will-change: transform, translate;
}
.tray[hidden] {
  display: none;
}
.viewport {
  position: relative;
  height: calc(9px + 172.8px);
  /* A plain clip, without rounded corners (a rounded clip over composited cards needs a mask),
     on a layer of its own: the cards sliding under it never change the tray's layer's size. */
  overflow: hidden;
  will-change: transform;
}
/* A card sits a card and a gap from the next: --offset is how many pages it is from the one on
   show. */
.card {
  position: absolute;
  top: 9px;
  left: 9px;
  display: flex;
  flex-direction: column;
  width: var(--card-w);
  height: 172.8px;
  border: 1px solid var(--card-edge);
  border-radius: 10px;
  background: var(--surface-2);
  box-shadow: 0 1px 2px var(--card-shade);
  transform: translateX(calc(var(--offset) * (var(--card-w) + 9px)));
  transition: transform 0.46s cubic-bezier(0.25, 0.9, 0.25, 1);
}
.cardHead {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 8px;
  padding: 12px 10.6px 0;
  font-size: 14.1px;
  line-height: 18px;
  white-space: nowrap;
}
.cardTitle {
  font-size: inherit;
  font-weight: 500;
  color: var(--card-ink);
}
.cardDate {
  color: var(--ink-muted);
}
.note {
  display: -webkit-box;
  height: 40px;
  margin-top: 6.7px !important;
  padding: 0 10.6px;
  overflow: hidden;
  color: var(--note);
  font-size: 14.3px;
  line-height: 20px;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
}
.people {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-top: auto;
  padding: 0 13.2px 12px 12px;
  border-bottom: 1px solid var(--rule);
}
.faces {
  display: flex;
}
.face {
  width: 21px;
  height: 21px;
  border-radius: 50%;
  background-size: cover;
  box-shadow: 0 0 0 1.5px var(--surface-2);
}
.face + .face {
  margin-left: -2px;
}
.face[data-person='theo'] {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23b9c4cf'/%3E%3Cpath d='M4 33c1.2-6.8 5.8-9.8 12-9.8s10.8 3 12 9.8z' fill='%23384150'/%3E%3Cpath d='M13.4 19.5h5.2v5h-5.2z' fill='%23b07a5c'/%3E%3Cellipse cx='16' cy='14.6' rx='6.1' ry='7' fill='%23c98f6c'/%3E%3Cpath d='M9.8 14.4c-.4-5.6 2.4-8.6 6.4-8.6 3.7 0 6.4 2.6 6 8.4-.7-2.4-1.9-3.6-3.4-4.1-1.9.8-4.4.9-6.7.3-1.2.8-1.9 2.1-2.3 4z' fill='%232a221d'/%3E%3Cpath d='M10.1 15.6c.2 4.7 2.6 7 5.9 7s5.7-2.3 5.9-7c-.8 1.7-1.7 2.6-2.8 2.8-.9-.7-2-1-3.1-1s-2.2.3-3.1 1c-1.1-.2-2-1.1-2.8-2.8z' fill='%232a221d'/%3E%3Cpath d='M14.4 19.6c.9.4 2.3.4 3.2 0' stroke='%23c98f6c' stroke-width='.8' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
}
.face[data-person='maya'] {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23d9c3b3'/%3E%3Cpath d='M8.4 16c-.2-6.6 3.1-10.4 7.6-10.4s7.8 3.8 7.6 10.4l.4 10H8z' fill='%232b1c16'/%3E%3Cpath d='M4.5 33c1.1-6.4 5.5-9.4 11.5-9.4s10.4 3 11.5 9.4z' fill='%238a5f4d'/%3E%3Cpath d='M13.6 20h4.8v4.6h-4.8z' fill='%23d39f84'/%3E%3Cellipse cx='16' cy='15' rx='5.6' ry='6.6' fill='%23e3b296'/%3E%3Cpath d='M10.3 14.4c.3-4.6 2.6-7 5.8-7 2.9 0 5.1 2 5.7 6.2-2.5-.7-4.7-2.1-6.1-4-1 2-2.8 3.7-5.4 4.8z' fill='%232b1c16'/%3E%3C/svg%3E");
}
.face[data-person='ines'] {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23c7d3c4'/%3E%3Cpath d='M4.5 33c1.1-6.4 5.5-9.4 11.5-9.4s10.4 3 11.5 9.4z' fill='%234f6b5c'/%3E%3Cpath d='M13.6 19.6h4.8v5h-4.8z' fill='%238c5a3f'/%3E%3Ccircle cx='10.2' cy='11.2' r='3.6' fill='%231f1712'/%3E%3Ccircle cx='21.8' cy='11.2' r='3.6' fill='%231f1712'/%3E%3Ccircle cx='16' cy='8.4' r='4.4' fill='%231f1712'/%3E%3Ccircle cx='9.4' cy='16' r='3' fill='%231f1712'/%3E%3Ccircle cx='22.6' cy='16' r='3' fill='%231f1712'/%3E%3Cellipse cx='16' cy='15.2' rx='5.6' ry='6.6' fill='%239c6646'/%3E%3Cpath d='M10.6 13.6c1.2-2.8 3.3-4 5.4-4s4.2 1.2 5.4 4c-1.8-1-3.6-1.4-5.4-1.4s-3.6.4-5.4 1.4z' fill='%231f1712'/%3E%3C/svg%3E");
}
.face[data-person='ravi'] {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23d6cbe0'/%3E%3Cpath d='M4 33c1.2-6.8 5.8-9.8 12-9.8s10.8 3 12 9.8z' fill='%235a4a72'/%3E%3Cpath d='M13.4 19.5h5.2v5h-5.2z' fill='%23a8714f'/%3E%3Cellipse cx='16' cy='14.8' rx='6' ry='6.9' fill='%23bf8360'/%3E%3Cpath d='M9.9 13.6c-.1-5 2.6-7.6 6.3-7.6 3.6 0 6.2 2.4 6 7.4-1.2-1.8-2.6-2.8-4.5-3.2-2.2.9-4.8 1-7.8 3.4z' fill='%233a2a22'/%3E%3Ccircle cx='13.6' cy='15' r='1.9' fill='none' stroke='%232a2a2a' stroke-width='.8'/%3E%3Ccircle cx='18.4' cy='15' r='1.9' fill='none' stroke='%232a2a2a' stroke-width='.8'/%3E%3Cpath d='M15.5 15h1' stroke='%232a2a2a' stroke-width='.8'/%3E%3C/svg%3E");
}
/* Saved, or published: a check in a disc. */
.state {
  position: relative;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: var(--saved);
}
.state[data-published] {
  background: var(--green);
}
.state::after {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 5.6px;
  height: 3px;
  margin: -2.6px 0 0 -2.8px;
  border-bottom: 1.3px solid var(--state-ink);
  border-left: 1.3px solid var(--state-ink);
  content: '';
  transform: rotate(-45deg);
}
.actions {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 8px;
  padding: 7px 9.5px 8px;
}
.actions button {
  height: 28.5px;
  padding: 0;
  border: 1px solid var(--button-edge);
  border-radius: 7px;
  background: var(--button);
  box-shadow: 0 1px 1px var(--button-shade);
  font-size: 14px;
  font-weight: 500;
  color: var(--button-ink);
  cursor: pointer;
  transition: background-color 0.15s ease;
}
@media (hover: hover) {
  .actions button:hover {
    background: var(--button-hover);
  }
}
.actions button:focus-visible,
.pager button:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}

/* The pager: Back, the page, Next. */
.pager {
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 38.3px;
  padding: 0 8px;
  font-size: 13.2px;
  line-height: 16px;
}
.pager button {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  height: 26px;
  padding: 0 6.5px;
  border: 0;
  border-radius: 6px;
  background: none;
  color: var(--pager);
  cursor: pointer;
}
.pager button:disabled {
  color: var(--pager-off);
  cursor: default;
}
.back::before,
.next::after {
  width: 6px;
  height: 6px;
  border-bottom: 1.3px solid currentColor;
  border-left: 1.3px solid currentColor;
  content: '';
}
.back::before {
  margin-left: 2px;
  transform: rotate(45deg);
}
.next::after {
  margin-right: 2px;
  transform: rotate(-135deg);
}
.count {
  color: var(--count);
}
.count b {
  font-weight: 400;
  color: var(--count-now);
}

/* The range: a tick per day, and over them a frosted handle a span of days wide. */
.scrubber {
  position: relative;
  height: 47px;
  margin-top: 6.5px;
  will-change: transform;
}
.step {
  position: absolute;
  top: 5px;
  left: 4px;
  width: 24px;
  height: 24px;
  padding: 0;
  border: 0;
  border-radius: 6px;
  background: none;
  color: var(--step);
  cursor: pointer;
}
.step::before {
  position: absolute;
  top: 9px;
  left: 10px;
  width: 6px;
  height: 6px;
  border-bottom: 1.3px solid currentColor;
  border-left: 1.3px solid currentColor;
  content: '';
  transform: rotate(45deg);
}
.forward {
  right: 4px;
  left: auto;
}
.forward::before {
  left: 7px;
  transform: rotate(-135deg);
}
.step:disabled {
  color: var(--step-off);
  cursor: default;
}
.step:focus-visible {
  outline: 2px solid var(--focus);
}
.track {
  position: absolute;
  top: 0;
  right: var(--gutter-r);
  left: var(--gutter-l);
  height: 33.5px;
}
.ticks {
  position: absolute;
  top: 10.75px;
  right: -26px;
  left: -26px;
  height: 12px;
  background-image: linear-gradient(90deg, var(--tick) 1.4px, transparent 1.4px);
  background-position: 26px 0;
  background-size: calc((100% - 52px) / var(--days)) 12px;
  -webkit-mask-image: linear-gradient(
    90deg,
    transparent,
    #000 44px,
    #000 calc(100% - 44px),
    transparent
  );
  mask-image: linear-gradient(90deg, transparent, #000 44px, #000 calc(100% - 44px), transparent);
}
/* The handle, and the band on the plot, each ride a carrier as wide as their row, moved by the
   range's first day (or, while it is dragged, by the fractional day the component writes on the
   panel as --drag). */
.carrier {
  position: absolute;
  inset: 0;
  transform: translateX(calc(var(--drag, var(--range)) / var(--days) * 100%));
  transition: transform 0.32s cubic-bezier(0.25, 0.9, 0.25, 1);
  pointer-events: none;
  will-change: transform;
}
.panel[data-dragging] .carrier {
  transition: none;
}
.band {
  position: absolute;
  top: -22px;
  bottom: -22px;
  left: 0;
  width: calc(var(--span) / var(--days) * 100%);
  background: var(--band);
  box-shadow:
    inset 1px 0 0 var(--band-edge),
    inset -1px 0 0 var(--band-edge);
  opacity: 0;
  transition: opacity 0.35s ease;
}
.panel[data-ranging] .band {
  opacity: 1;
}
.range {
  position: absolute;
  top: 0;
  left: -20px;
  display: grid;
  place-items: center;
  width: calc(var(--span) / var(--days) * 100% + 40px);
  height: 33.5px;
  overflow: hidden;
  border: 1px solid var(--handle-edge);
  border-radius: 10px;
  background: var(--handle);
  box-shadow:
    0 2px 8px var(--handle-far),
    0 1px 2px var(--handle-near);
  cursor: grab;
  pointer-events: auto;
  touch-action: pan-y;
  -webkit-user-select: none;
  user-select: none;
}
.range:active {
  cursor: grabbing;
}
.range:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
/* Under the frosted handle the ticks show soft, as if seen through it, from edge to edge. The
   handle's span of days starts 16 px into the ghost, so its ticks fall on the track's at whole
   days; while the handle is dragged between days, --shift moves them back by the part of a day
   it has gone. It never reaches past the handle, so the handle's layer keeps one size. */
.ghost {
  position: absolute;
  top: 10px;
  /* 4 px in from the handle's edges, less its border. */
  right: 3px;
  left: 3px;
  height: 12px;
  background-image: linear-gradient(
    90deg,
    transparent 0,
    var(--frost) 25%,
    var(--frost) 40%,
    transparent 70%
  );
  background-position: calc(16px + var(--shift, 0px) - 1.5px) 0;
  background-size: calc((100% - 32px) / var(--span)) 12px;
}
/* The label clears the ticks around it. */
.rangeLabel {
  position: relative;
  padding: 0 7px;
  background: var(--handle);
  font-size: 13px;
  font-weight: 500;
  color: var(--handle-ink);
  white-space: nowrap;
}

/* The months under the range, the one under its middle in the ink of the titles. */
.months {
  position: absolute;
  top: 264px;
  right: var(--gutter-r);
  left: var(--gutter-l);
  height: 18px;
  /* The month in bold changes as the range moves: it lays out this row alone. */
  contain: size layout style;
  will-change: transform;
}
.months span {
  position: absolute;
  top: 0;
  left: calc(var(--x) * 100%);
  font-size: 14.2px;
  line-height: 18px;
  color: var(--month);
  white-space: nowrap;
  transform: translateX(-50%);
  transition: color 0.25s ease;
}
.months span[data-current] {
  color: var(--month-now);
  font-weight: 500;
}

/* The demo's pointer moves over the whole window, in a box of its own. */
.pointerLayer {
  position: absolute;
  z-index: 10;
  inset: 0;
  contain: strict;
  pointer-events: none;
}
/* The arrow and the hand swap by their opacity, each on a layer of its own: a change of style
   that reached the drawings inside (visibility does) would lay their SVGs out again. */
.pointer [data-glyph] {
  will-change: opacity;
}
.pointer [data-glyph='hand'],
.pointer[data-hand] [data-glyph='arrow'] {
  opacity: 0;
}
.pointer[data-hand] [data-glyph='hand'] {
  opacity: 1;
}

/* For screen readers only. */
.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  will-change: opacity;
}

/* Narrow: no side bar, tighter gutters, a tray that fits the panel and smaller months. */
@container (max-width: 599px) {
  .sidebar {
    display: none;
  }
  .canvas {
    left: 9px;
  }
  .panel {
    --gutter-l: 40px;
    --gutter-r: 30px;
    --tray-w: min(367px, 100cqw - 52px);
    --card-w: calc(var(--tray-w) - 48px);
    --panel-w: calc(100cqw - 38px);
    left: 18px;
  }
  .head {
    padding-left: 14px;
  }
  .title {
    font-size: 14px;
  }
  .hours span {
    left: 9px;
  }
  .months span {
    font-size: 12px;
  }
}
/* Narrower still: the card's date goes under its title, its note keeps one line, and the type
   under the range is smaller. */
@container (max-width: 329px) {
  .cardHead {
    flex-direction: column;
    align-items: flex-start;
    gap: 1px;
    font-size: 12.4px;
    line-height: 16px;
  }
  .note {
    height: 19px;
    font-size: 13px;
    line-height: 19px;
    -webkit-line-clamp: 1;
    line-clamp: 1;
  }
  .months span {
    font-size: 10.5px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .dot,
  .mark,
  .card,
  .carrier,
  .band,
  .months span,
  .actions button {
    transition: none;
  }
}
```

### version-history/versions.ts

```tsx
/** Someone who saved versions; their illustrated avatar is drawn in the stylesheet. */
export type Person = 'theo' | 'maya' | 'ines' | 'ravi';

export const PEOPLE: Record<Person, string> = {
  theo: 'Theo',
  maya: 'Maya',
  ines: 'Ines',
  ravi: 'Ravi',
};

/** A saved version of the workflow. */
export type Version = {
  /** Its number, counted from the workflow's first version. */
  number: number;
  /** When it was saved, in minutes after midnight. */
  time: number;
  /** What changed, in a sentence. */
  note: string;
  /** Who made it. */
  authors: Person[];
  /** How much it changed, in edited fields: the dot grows with it. */
  changes: number;
  /** It went live. */
  published?: boolean;
};

/** The versions saved on one day within the same hour or so: one dot on the timeline. */
export type Spot = {
  /** The day, counted from the timeline's first day. */
  day: number;
  /** Its versions, oldest first. */
  versions: Version[];
};

const at = (hours: number, minutes: number) => hours * 60 + minutes;

/** Sample history, September 1 to December 2. */
export const SPOTS: Spot[] = [
  {
    day: 6,
    versions: [
      {
        number: 33,
        time: at(18, 52),
        note: 'Created the workflow with a webhook trigger and a draft summary step.',
        authors: ['theo'],
        changes: 3,
      },
      {
        number: 34,
        time: at(19, 8),
        note: 'Tidied the trigger fields and renamed the first step.',
        authors: ['theo'],
        changes: 2,
      },
    ],
  },
  {
    day: 9,
    versions: [
      {
        number: 35,
        time: at(10, 41),
        note: 'Rebuilt the summary prompt around the new request format.',
        authors: ['maya', 'theo'],
        changes: 9,
      },
      {
        number: 36,
        time: at(10, 58),
        note: 'Added the message node and mapped the task fields into it.',
        authors: ['maya'],
        changes: 8,
      },
      {
        number: 37,
        time: at(11, 6),
        note: 'Split the long prompt into a system note and a template.',
        authors: ['maya'],
        changes: 6,
      },
      {
        number: 38,
        time: at(11, 20),
        note: 'Tested the flow end to end and fixed two empty fields.',
        authors: ['theo'],
        changes: 5,
      },
      {
        number: 39,
        time: at(11, 31),
        note: 'Added a fallback for requests that have no due date.',
        authors: ['theo', 'maya'],
        changes: 6,
      },
      {
        number: 40,
        time: at(11, 46),
        note: 'Moved the formatting into its own step before posting.',
        authors: ['maya'],
        changes: 7,
      },
    ],
  },
  {
    day: 19,
    versions: [
      {
        number: 41,
        time: at(9, 21),
        note: 'Shortened the summary and capped it at five bullet points.',
        authors: ['ines'],
        changes: 2,
      },
      {
        number: 42,
        time: at(9, 37),
        note: 'Switched the channel to #testing while the flow is reviewed.',
        authors: ['ines'],
        changes: 1,
      },
      {
        number: 43,
        time: at(9, 55),
        note: 'Added the name of whoever asked to the message header.',
        authors: ['ines', 'theo'],
        changes: 2,
      },
    ],
  },
  {
    day: 27,
    versions: [
      {
        number: 44,
        time: at(18, 9),
        note: 'Cleaned up unused variables before the first release.',
        authors: ['theo'],
        changes: 4,
      },
      {
        number: 45,
        time: at(18, 33),
        note: 'Published the first version for the support team.',
        authors: ['theo', 'maya'],
        changes: 6,
        published: true,
      },
    ],
  },
  {
    day: 32,
    versions: [
      {
        number: 46,
        time: at(13, 2),
        note: 'Tried posting again when the channel rejects a message.',
        authors: ['ravi'],
        changes: 1,
      },
      {
        number: 47,
        time: at(13, 15),
        note: 'Grouped related requests into one thread.',
        authors: ['ravi'],
        changes: 2,
      },
      {
        number: 48,
        time: at(13, 34),
        note: 'Tuned the model settings for shorter, steadier answers.',
        authors: ['ravi', 'ines'],
        changes: 2,
      },
    ],
  },
  {
    day: 39,
    versions: [
      {
        number: 49,
        time: at(10, 2),
        note: 'Connected the task output to the message node.',
        authors: ['maya'],
        changes: 2,
      },
      {
        number: 50,
        time: at(10, 9),
        note: 'Renamed the steps so the canvas reads from top to bottom.',
        authors: ['maya'],
        changes: 1,
      },
      {
        number: 51,
        time: at(10, 15),
        note: 'Added a retry step for when the channel is busy.',
        authors: ['theo', 'maya'],
        changes: 2,
      },
      {
        number: 52,
        time: at(10, 21),
        note: 'Adjusted the summary prompt and message spacing for long threads.',
        authors: ['theo', 'ines'],
        changes: 2,
      },
      {
        number: 53,
        time: at(10, 35),
        note: 'Refined Quillo node parameters and updated message formatting.',
        authors: ['theo', 'maya'],
        changes: 3,
      },
    ],
  },
  {
    day: 43,
    versions: [
      {
        number: 54,
        time: at(15, 6),
        note: 'Logged the summary of each run to the Quillo table.',
        authors: ['ravi'],
        changes: 2,
      },
    ],
  },
  {
    day: 52,
    versions: [
      {
        number: 55,
        time: at(16, 28),
        note: 'Added a label for urgent requests.',
        authors: ['ines'],
        changes: 3,
      },
      {
        number: 56,
        time: at(16, 52),
        note: 'Published the urgent label and the new thread layout.',
        authors: ['ines', 'theo'],
        changes: 5,
        published: true,
      },
    ],
  },
  {
    day: 56,
    versions: [
      {
        number: 57,
        time: at(13, 24),
        note: 'Trimmed the prompt and removed a repeated instruction.',
        authors: ['maya'],
        changes: 2,
      },
      {
        number: 58,
        time: at(13, 40),
        note: 'Fixed links that broke inside code blocks.',
        authors: ['maya'],
        changes: 1,
      },
    ],
  },
  {
    day: 58,
    versions: [
      {
        number: 59,
        time: at(19, 16),
        note: 'Added a weekly digest branch, switched off for now.',
        authors: ['ravi'],
        changes: 3,
      },
    ],
  },
  {
    day: 65,
    versions: [
      {
        number: 60,
        time: at(15, 22),
        note: 'Updated the Quillo fields after the table was renamed.',
        authors: ['theo'],
        changes: 2,
      },
    ],
  },
  {
    day: 73,
    versions: [
      {
        number: 61,
        time: at(11, 35),
        note: 'Mentioned the person on call when a request is urgent.',
        authors: ['ines'],
        changes: 2,
      },
    ],
  },
  {
    day: 82,
    versions: [
      {
        number: 62,
        time: at(12, 22),
        note: 'Reworded the message header and the footer link.',
        authors: ['maya'],
        changes: 1,
      },
    ],
  },
  {
    day: 83,
    versions: [
      {
        number: 63,
        time: at(18, 57),
        note: 'Turned the weekly digest on for the whole team.',
        authors: ['ravi'],
        changes: 2,
      },
      {
        number: 64,
        time: at(19, 19),
        note: 'Published the digest and the mentions for the person on call.',
        authors: ['ravi', 'ines'],
        changes: 4,
        published: true,
      },
    ],
  },
  {
    day: 88,
    versions: [
      {
        number: 65,
        time: at(7, 18),
        note: 'Raised the time limit for slow requests.',
        authors: ['theo'],
        changes: 1,
      },
    ],
  },
  {
    day: 92,
    versions: [
      {
        number: 66,
        time: at(14, 31),
        note: 'Moved the flow to the new message format.',
        authors: ['maya', 'ravi'],
        changes: 9,
      },
      {
        number: 67,
        time: at(14, 48),
        note: 'Added test cases for empty and very long requests.',
        authors: ['ravi'],
        changes: 7,
      },
      {
        number: 68,
        time: at(15, 2),
        note: 'Retuned the summary for the new format.',
        authors: ['maya'],
        changes: 6,
      },
      {
        number: 69,
        time: at(15, 14),
        note: 'Removed the old webhook after the switch.',
        authors: ['theo'],
        changes: 5,
      },
      {
        number: 70,
        time: at(15, 29),
        note: 'Checked every branch against the test cases.',
        authors: ['ravi', 'theo'],
        changes: 6,
      },
      {
        number: 71,
        time: at(15, 41),
        note: 'Wrote the release notes for the next publish.',
        authors: ['maya'],
        changes: 4,
      },
    ],
  },
];
```

### version-history/icons.tsx

```tsx
/*
 * The canvas' drawings: the settings gear, the model's mark, the chat tool's mark and the check
 * in the Output tag. They never change once drawn (the timeline's own marks are HTML boxes, so
 * that nothing inside an SVG is ever restyled while the demo plays).
 */

export function GearIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">
      <path
        d="M6.7 1.9h2.6l.4 1.7c.4.2.8.4 1.1.7l1.7-.5 1.3 2.2-1.3 1.2a4 4 0 0 1 0 1.3l1.3 1.2-1.3 2.2-1.7-.5c-.3.3-.7.5-1.1.7l-.4 1.7H6.7l-.4-1.7a4 4 0 0 1-1.1-.7l-1.7.5-1.3-2.2 1.3-1.2a4 4 0 0 1 0-1.3L2.2 6l1.3-2.2 1.7.5c.3-.3.7-.5 1.1-.7z"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.2"
        strokeLinejoin="round"
      />
      <circle cx="8" cy="8" r="2" fill="none" stroke="currentColor" strokeWidth="1.2" />
    </svg>
  );
}

/** The model's mark: a four-pointed spark in a ring. */
export function ModelMark({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">
      <circle cx="8" cy="8" r="6.6" fill="none" stroke="currentColor" strokeWidth="1.2" />
      <path
        d="M8 3.6c.3 2.3 1.2 3.4 3.4 4.4-2.2 1-3.1 2.1-3.4 4.4-.3-2.3-1.2-3.4-3.4-4.4 2.2-1 3.1-2.1 3.4-4.4z"
        fill="currentColor"
      />
    </svg>
  );
}

/** The chat tool's mark: two speech bubbles on a tile. */
export function ChatMark({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">
      <rect x="1" y="1" width="14" height="14" rx="4" fill="#6c63ff" />
      <path d="M4 4.6h6.2v4H6.4L4.6 10V8.6H4z" fill="#fff" />
      <path d="M7.8 9.6h1.6l2-1.6h.6v3.4h-.6v1.3l-1.6-1.3H7.8z" fill="#c8f1e3" />
    </svg>
  );
}

export function CheckCircle({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">
      <circle cx="8" cy="8" r="6.2" fill="none" stroke="currentColor" strokeWidth="1.3" />
      <path
        d="m5.4 8.2 1.8 1.8 3.4-3.6"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.3"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}
```

### version-history/demo-pointer.tsx

```tsx
'use client';

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

/*
 * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait,
 * glide the pointer onto a dot or a button and press it, or grab the range and drag it, calling
 * the component's own handlers as a hand would. Stopping the demo (the reader takes over, or it
 * scrolls away) ends the step in progress at once: its promise rejects, which ends the script
 * right there, and the pointer fades out.
 *
 * Nothing the pointer does needs a layout: it moves by animations the compositor plays (a drag
 * too: the dragged value follows that animation's clock), the press scales a box that keeps a
 * transform at rest, and the arrow and the pointing hand are HTML boxes around drawings that
 * never change, swapped by their opacity (the stylesheet's rule, from `data-hand`).
 */

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

/** The arrow, or the pointing hand over something that can be pressed. */
export type Glyph = 'arrow' | 'hand';

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /**
   * Glides the pointer onto `target`: its middle, or a point given as shares of its size; it
   * shows `glyph` once there.
   */
  moveTo: (target: Element | null | undefined, glyph?: Glyph, at?: Point) => Promise<void>;
  /** Glides the pointer to a point of the screen, in client px, as an arrow. */
  moveToPoint: (point: Point) => Promise<void>;
  /** Presses and lets go; `action` runs as the pointer goes down. */
  press: (action: () => void) => Promise<void>;
  /**
   * Holds, drags to a point of the screen (client px) over `ms`, and lets go. `step` gets the
   * share of the way done on every frame, so the dragged value moves with the hand.
   */
  drag: (to: Point, ms: number, step: (share: number) => void) => Promise<void>;
};

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

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

const place = ({ x, y }: Point) => `translate(${x - TIP.x}px, ${y - TIP.y}px)`;
const clamp = (value: number) => Math.min(1, Math.max(0, value));
const easeInOut = (t: number) => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2);

/** 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;
  let frame = 0;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.86, y: stage.offsetHeight * 0.94 };

  /** 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 show = (glyph: Glyph) => {
    if (glyph === 'hand') pointer.dataset.hand = '';
    else delete pointer.dataset.hand;
  };

  /** A point of the screen in the stage's own px (the stage may sit in a scaled container). */
  const toStage = (point: Point): Point => {
    const box = stage.getBoundingClientRect();
    const scale = box.width / stage.offsetWidth || 1;
    return { x: (point.x - box.left) / scale, y: (point.y - box.top) / scale };
  };

  const glide = (to: Point, glyph: Glyph) => {
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.16, y: (from.y + to.y) / 2 + dx * 0.16 };
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 12; i += 1) {
      const t = i / 12;
      const u = 1 - t;
      keyframes.push({
        transform: place({
          x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
          y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
        }),
      });
    }
    flight?.cancel();
    show('arrow');
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    const motion = pointer.animate(keyframes, {
      duration: Math.min(950, Math.max(360, Math.hypot(dx, dy) * 1.6)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    flight = motion;
    return step(motion.finished.then(() => show(glyph)));
  };

  const moveTo: Demo['moveTo'] = (target, glyph = 'hand', share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    const box = target.getBoundingClientRect();
    const point = { x: box.left + box.width * share.x, y: box.top + box.height * share.y };
    return glide(toStage(point), glyph);
  };

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

  const drag: Demo['drag'] = (point, ms, onStep) => {
    const from = at;
    const to = toStage(point);
    at = to;
    // The hand's path, eased, as an animation; the value reads the same clock every frame.
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 16; i += 1) {
      const share = easeInOut(i / 16);
      keyframes.push({
        transform: place({
          x: from.x + (to.x - from.x) * share,
          y: from.y + (to.y - from.y) * share,
        }),
      });
    }
    flight?.cancel();
    pointer.style.transform = place(to);
    const hold = pointer.firstElementChild?.animate([{ transform: 'scale(0.88)' }], {
      duration: 120,
      fill: 'forwards',
    });
    const motion = pointer.animate(keyframes, { duration: ms, easing: 'linear' });
    flight = motion;
    return step(
      new Promise<void>((resolve) => {
        const tick = () => {
          const time = motion.currentTime;
          // Clamped at both ends; a finished animation is the end of the way.
          const t =
            motion.playState === 'finished' ? 1 : clamp(typeof time === 'number' ? time / ms : 0);
          onStep(easeInOut(t));
          if (t < 1) frame = requestAnimationFrame(tick);
          else {
            frame = 0;
            hold?.cancel();
            resolve();
          }
        };
        frame = requestAnimationFrame(tick);
      }),
    );
  };

  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({
    wait,
    moveTo,
    moveToPoint: (point) => glide(toStage(point), 'arrow'),
    press,
    drag,
  }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

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

/** The pointer's box: it moves, on a layer of its own. */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  display: 'block',
  width: 22,
  height: 22,
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
  willChange: 'transform',
};

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

/** One glyph, over the other: the 20 × 20 drawing at 22 px. */
const GLYPH: CSSProperties = { position: 'absolute', top: 0, left: 0, display: 'block' };
const DRAWING: CSSProperties = { display: 'block', width: 22, height: 22, overflow: 'visible' };

/** Its colours, the stage's tokens (version-history.module.css): as the system's, on both schemes. */
const ARROW: CSSProperties = { fill: 'var(--pointer)', stroke: 'var(--pointer-edge)' };
const PALM: CSSProperties = { fill: 'var(--pointer-edge)', stroke: 'var(--pointer)' };
const CREASES: CSSProperties = { stroke: 'var(--pointer)' };

/**
 * The pointer the demo moves around: an arrow, or a pointing hand over what it can press. It is
 * decorative: the demo's effects show on the timeline itself. The stylesheet shows one glyph or
 * the other from the `data-hand` attribute, by their opacity.
 */
export function DemoPointer({ ref, className }: { ref: Ref<HTMLSpanElement>; className?: string }) {
  return (
    <span ref={ref} className={className} style={POINTER} aria-hidden="true">
      <span style={PRESS}>
        <span data-glyph="arrow" 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 data-glyph="hand" style={GLYPH}>
          <svg viewBox="0 0 20 20" style={DRAWING}>
            {/* The fingertip sits on the arrow's tip. */}
            <g transform="translate(-4.6 -0.4)">
              <path
                d="M7.6 2.4c.8 0 1.4.6 1.4 1.4v5l.4-.1c.8-.1 1.4.4 1.5 1.1.3-.4.8-.6 1.3-.5.7.1 1.2.6 1.2 1.3.3-.3.7-.4 1.1-.4.8 0 1.3.6 1.3 1.4v3.2c0 2.6-2 4.6-4.6 4.6h-1.6c-1.5 0-2.7-.6-3.6-1.8l-2.9-4c-.4-.6-.3-1.4.3-1.8s1.3-.3 1.7.2l.7.9V3.8c0-.8.6-1.4 1.4-1.4z"
                style={PALM}
                strokeWidth="1.1"
                strokeLinejoin="round"
              />
              <path
                d="M10.2 12.4v2.8M12.6 12.8v2.4"
                fill="none"
                style={CREASES}
                strokeWidth="0.9"
                strokeLinecap="round"
              />
            </g>
          </svg>
        </span>
      </span>
    </span>
  );
}
```

### version-history/use-autoplay.ts

```tsx
'use client';

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

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

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

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

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