# Event setup

> An event card edited where it stands: the title turns into a field, the date opens a calendar, the time splits in two, and selected text gets a formatting toolbar.

A card of an event’s details, each edited in place. Press the title and a grey field grows around it, the date springs a month calendar out of its corner, the time splits into a field per end, and selecting text in the description brings up a dark toolbar with colours, highlights, a link, bold, italic and strikethrough. Saved, each field folds back into text.

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

Category: Inputs.

## Anatomy

1. **Card**: A grey frame around a lighter panel (a step lighter at night), with the heading and a close button on the frame.
2. **Close**: Folds the card into a button that opens it again, and calls `onClose`.
3. **Row label**: An icon and a name, muted, on the left of each row. It never moves.
4. **Value**: A button that reads as text. A pencil slides in under the pointer; a press turns it into its editor.
5. **Time range**: Two times and a dash. Pressed, each time glides into a field of its own and the dash fades.
6. **Description**: Rich text edited where it stands: the sheet turns grey while it is being edited, Enter is turned away.
7. **Format toolbar**: Comes up over a selection, centred on it: text colour, highlight, link, bold, italic and strikethrough.

## When to use

- A short record of a few fields (a title, a date, a time, a note) that people read far more often than they change.
- Changing one detail at a time, in place, without opening a form or leaving the view.
- A side panel or a popover beside a calendar or a list, where every value is already on screen.
- A description that needs light formatting (a colour, a highlight, a link, bold) and no more.

## When not to use

- Creating an event from nothing, with every field empty. Use a form with its fields open and one Save.
- Many fields, or fields that depend on each other (repeats, guests, time zones). Use a full editor.
- Long documents with headings, lists or pictures. Use a real rich-text editor.
- Values that must be reviewed together before they count: here each one saves on its own.
- A read-only summary. Plain text is enough; the pencils promise an edit.

## Installation

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

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

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

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

## Usage

```tsx
'use client';

import { EventSetup } from './event-setup';

export function TeamEvent() {
  return (
    <EventSetup
      defaultTitle="Design Meet"
      defaultDate="2026-04-24"
      defaultStart="08:00"
      defaultEnd="22:00"
      autoplay={false}
      onChange={(event) => console.log(event)}
    />
  );
}
```

## States

- **Resting**: Every value as text, with the description’s formats kept as they were saved.
- **Editing the title**: A grey pill grows around the text, with a check and a cross. Enter or a press elsewhere saves, Escape cancels.
- **Picking a date**: The calendar springs out of the value, Monday first; the picked day is a blue square that glides between days.
- **Editing the time**: One field per end with its AM/PM. Digits type a time, the arrow keys move it by 15 minutes.
- **Text selected**: The toolbar over the selection; its buttons show the formats the selection already has.
- **Highlight colours**: A row of swatches above the toolbar, the current one ringed. Text colour opens the same row.
- **Adding a link**: The toolbar turns into an address field; an address without a scheme gets https://.
- **Closed**: The card fades and shrinks a little; a pill takes its place and brings it back.

## How it works

- Each value is a button that turns into its editor. The switch is played FLIP: pieces are measured before and after, the text glides into place in the field, and the grey shape behind it stretches by its offsets and fades by its colour, so it never becomes a layer redrawn at every size.
- The time splits the same way: “8:00 AM – 10:00 PM” comes apart into two small fields, each time and its AM or PM gliding into one, while the dash fades and a check pops in.
- The calendar is a month grid, Monday first, with the picked day as a blue square that glides between days. It hangs above the date and springs open from its corner, and a turned month slides in from the side it comes from.
- The description is a contentEditable paragraph kept as a list of runs: pieces of text with their marks. The browser types into it and the runs are read back from the DOM. A format cuts the runs at the selection’s edges, rewrites the HTML and puts the selection back by its character offsets. Enter, rich pastes and the browser’s own formatting are turned away, and undo works on the runs.
- The toolbar is centred over the selection and glides to the next one. The demo’s selection is painted with the CSS Custom Highlight API, the way the browser paints its own, so the demo never touches the page’s selection or focus.
- Every colour is a light-dark() token on the root, shared by the calendar and the description. The description writes its text colours and highlights as references to those tokens, so formatted text changes with the scheme, and the morphs read the fields’ grey from the computed style as each one starts.

## API reference

- **defaultTitle** (`string`, default `'Design Meet'`): The event’s name.
- **defaultDate** (`string`, default `'2026-04-24'`): Its day, as “YYYY-MM-DD”.
- **defaultStart** (`string`, default `'08:00'`): When it starts, as “HH:MM” on a 24-hour clock.
- **defaultEnd** (`string`, default `'22:00'`): When it ends, as “HH:MM”.
- **defaultDescription** (`string | Run[]`, default `DESCRIPTION`): The description as plain text (by default, a team check-in), formatted in the card, or as runs of text with their marks: bold, italic, strike, colour, highlight, link.
- **defaultState** (`EventSetupState`): Opens in a state, with the demo off: an editor open (editing), a phrase selected with the toolbar over it (selection) and one of its menus open (menu), or the card closed.
- **onChange** (`(details: EventDetails) => void`): Called after every saved change with the title, date, start, end and the description’s runs.
- **onClose** (`() => void`): Called when the card is closed; it folds into a “Setup events” button that opens it again.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a ghost pointer edits the title, formats two phrases, picks a date and moves the start time. Set it to false in a product.
- **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

- Each value is a labelled button (“Title: Design Meet. Edit”) that opens its editor and moves the focus into it. Enter or a press elsewhere saves, Escape cancels, and the focus goes back to the value. Changes are announced politely (“Date set: Tuesday, April 28, 2026.”).
- The calendar is a dialog holding a grid. The arrow keys move a day or a week, Page Up and Page Down a month (with Shift, a year), and Home and End go to the ends of the week. Space picks a day and Enter picks and applies. Each day is named by its full date.
- The description is a multi-line text box named by its label. With text selected, Tab reaches a labelled toolbar whose buttons the arrow keys move along. B, I and S are toggles with aria-pressed, the colours open rows of named swatches, and Escape returns to the text. Cmd or Ctrl with B, I, Shift-X, Z and Shift-Z work in the text.
- Under reduced motion the editors open at once, nothing glides, and the demo does not play.

## Source

### event-setup/event-setup.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
} from 'react';
import { addDays, isoOf, longDate, parseClock, parseDay, shortDate, type Day } from './dates';
import { DatePicker } from './date-picker';
import { Description, type DescriptionHandle } from './description';
import { GhostPointer, runDemo, type Demo } from './ghost-pointer';
import { CloseIcon, DateIcon, DescriptionIcon, PencilIcon, TimeIcon, TitleIcon } from './icons';
import { normalize, type Run, type Span } from './rich-text';
import { spring } from './spring';
import { draftOf, TimeField, type TimeDraft } from './time-field';
import { TitleField, type TitleDemo } from './title-field';
import { useAutoplay } from './use-autoplay';
import styles from './event-setup.module.css';

/*
 * Event setup: a card of an event's details, every one of them edited where it stands.
 *
 * - The title turns into a grey field around its own text, with a check and a cross; the time
 *   splits into two small fields, one per end; both morph back into text when saved (FLIP, see
 *   flip.ts). Enter or a press elsewhere saves, Escape cancels.
 * - The date opens a calendar that springs out of it: a month grid with the picked day as a blue
 *   square that glides between days, and Cancel and Apply.
 * - The description is rich text, edited in place: select some of it and a dark toolbar comes up
 *   over the selection with text colours, highlights, a link, bold, italic and strikethrough.
 * - Every change a reader makes is announced. While the card is on screen and untouched, a ghost
 *   pointer edits the title, formats two phrases, picks a date and moves the start time.
 * - Light or dark: every colour is a light-dark() token in the CSS (the description's text colours
 *   and highlights too), so the scheme is the page's color-scheme, or the one the colorScheme prop
 *   sets. The one colour script reads, the fields' grey for the morphs, comes from the computed
 *   style as each morph starts.
 */

export type EventDetails = {
  title: string;
  /** "YYYY-MM-DD". */
  date: string;
  /** "HH:MM", on a 24-hour clock. */
  start: string;
  end: string;
  /** The description's runs: text, and its marks. */
  description: Run[];
};

type Editor = 'title' | 'date' | 'time';

/**
 * A state to open in, for docs and tests: an editor open, a phrase of the description selected
 * with the toolbar over it (and one of its menus open), or the card closed. Setting it turns the
 * demo off.
 */
export type EventSetupState = {
  /** The title's field, the calendar or the two time fields, open. */
  editing?: Editor;
  /** A phrase of the description, selected (its first occurrence), with the toolbar over it. */
  selection?: string;
  /** With a selection: the toolbar's text colours, highlights or link field, open. */
  menu?: 'color' | 'highlight' | 'link';
  /** Folded into the button that opens it again. */
  closed?: boolean;
};

/** The morphs between a value and its editor: quick, with a touch of bounce. */
const MORPH = spring(0.16, 430);

const DESCRIPTION =
  'A short weekly check-in where the team lines up on what shipped, what’s blocked, and what’s coming. We’ll walk through the sprint board, call out risks early, and note anything that needs help from other teams.';

/** What the demo does to the description, in turns: one phrase highlighted, one in bold. */
const DEMO_HIGHLIGHT = 'what’s blocked';
const DEMO_BOLD = 'call out risks early';

/** "08:30" to minutes after midnight. */
const minutesOf = (text: string, fallback: number) => {
  const match = /^(\d{1,2}):(\d{2})$/.exec(text);
  return match && +match[1] < 24 && +match[2] < 60 ? +match[1] * 60 + +match[2] : fallback;
};
const hhmm = (minutes: number) =>
  `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`;
const spokenTime = (start: number, end: number) => {
  const { start: from, end: to } = draftOf(start, end);
  return `${from.time} ${from.meridiem} to ${to.time} ${to.meridiem}`;
};

/** A click as a pointer makes it (not a keyboard's, which moves the focus). */
const tap = (element: Element | null | undefined) =>
  element?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, detail: 1 }));

/** The demo's way with a phrase of the description: select it, then format it from the toolbar. */
async function formatPhrase(
  { wait, moveTo, press, fromScreen }: Demo,
  act: (element: Element | null | undefined) => Promise<void>,
  handle: DescriptionHandle,
  phrase: string,
  tool: 'highlight' | 'bold',
  swatch: string | null,
) {
  const start = handle.text().indexOf(phrase);
  if (start < 0) return;
  /** A letter's left or right edge, halfway up its line, on the stage. */
  const edge = (offset: number, side: 'left' | 'right') => {
    const rect = handle.rectsOf({ start: offset, end: offset + 1 })[0];
    if (!rect) return null;
    return fromScreen(side === 'left' ? rect.left : rect.right, rect.top + rect.height / 2);
  };
  // The I-beam goes to the phrase's first letter, then drags along it a word at a time.
  await moveTo(edge(start, 'left'), { shape: 'text' });
  await wait(220);
  const ends = Array.from(phrase.matchAll(/\S+/g), (word) => start + word.index + word[0].length);
  for (const end of ends) {
    await moveTo(edge(end - 1, 'right'), { shape: 'text', ms: 150 });
    handle.showSelection({ start, end } satisfies Span);
  }
  await wait(520);
  const bar = handle.toolbar();
  const button = bar?.querySelector(`[data-tool="${tool}"]`);
  await moveTo(button, { shape: 'hand' });
  await act(button);
  if (swatch) {
    await wait(420);
    const choice = bar?.querySelector(`[data-swatch="${swatch}"]`);
    await moveTo(choice, { shape: 'hand' });
    await act(choice);
  }
  await wait(700);
  // A click a little further on ends the selection.
  await moveTo(edge(start + phrase.length + 3, 'left'), { shape: 'text' });
  await press(() => handle.showSelection(null));
  await wait(450);
}

export function EventSetup({
  defaultTitle = 'Design Meet',
  defaultDate = '2026-04-24',
  defaultStart = '08:00',
  defaultEnd = '22:00',
  defaultDescription = DESCRIPTION,
  onChange,
  onClose,
  autoplay = true,
  defaultState,
  colorScheme,
  className = '',
}: {
  /** The event's name. */
  defaultTitle?: string;
  /** Its day, as "YYYY-MM-DD". */
  defaultDate?: string;
  /** When it starts, as "HH:MM" on a 24-hour clock. */
  defaultStart?: string;
  /** When it ends, as "HH:MM" on a 24-hour clock. */
  defaultEnd?: string;
  /** The description, as plain text (formats are added in the card), or as formatted runs. */
  defaultDescription?: string | Run[];
  /** Called with all the details after every saved change. */
  onChange?: (details: EventDetails) => void;
  /** Called when the card is closed; it folds into a button that opens it again. */
  onClose?: () => void;
  /** While on screen and untouched, a ghost pointer edits the card on its own. */
  autoplay?: boolean;
  /** Opens in this state, with the demo off. */
  defaultState?: EventSetupState;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(7000);
  const [title, setTitle] = useState(defaultTitle);
  const [date, setDate] = useState(() => parseDay(defaultDate));
  const [time, setTime] = useState(() => ({
    start: minutesOf(defaultStart, 8 * 60),
    end: minutesOf(defaultEnd, 22 * 60),
  }));
  const [initialRuns] = useState<Run[]>(() =>
    normalize(
      typeof defaultDescription === 'string'
        ? [{ text: defaultDescription, marks: {} }]
        : defaultDescription.map((run) => ({ text: run.text, marks: { ...run.marks } })),
    ),
  );
  /**
   * The editor that is open, and whether a reader opened it (it then takes the focus); `quiet`
   * when it opened with the card, from defaultState, so it leaves the focus where it is.
   */
  const [open, setOpen] = useState<{ editor: Editor; byReader: boolean; quiet?: boolean } | null>(
    () =>
      defaultState?.editing ? { editor: defaultState.editing, byReader: true, quiet: true } : null,
  );
  const [titleDraft, setTitleDraft] = useState(title);
  const [timeDraft, setTimeDraft] = useState<TimeDraft>(() => draftOf(time.start, time.end));
  const [dateDraft, setDateDraft] = useState<Day>(date);
  const [titleDemo, setTitleDemo] = useState<TitleDemo>(null);
  const [closed, setClosed] = useState(!!defaultState?.closed);
  const [message, setMessage] = useState('');
  const headingId = useId();
  const descriptionLabel = useId();

  const titleValue = useRef<HTMLButtonElement>(null);
  const titleSave = useRef<HTMLButtonElement>(null);
  const dateValue = useRef<HTMLButtonElement>(null);
  const datePopover = useRef<HTMLDivElement>(null);
  const timeValue = useRef<HTMLButtonElement>(null);
  const timeBox = useRef<HTMLDivElement>(null);
  const description = useRef<DescriptionHandle>(null);
  const closeButton = useRef<HTMLButtonElement>(null);
  const reopenButton = useRef<HTMLButtonElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const runs = useRef(initialRuns);
  /** True while the demo presses something: its changes are not announced. */
  const demoActing = useRef(false);
  /**
   * Where the focus goes once a change the reader made with the keyboard has rendered: a value
   * (its button is mounted again by then), the close button or the button that reopens the card.
   */
  const focusNext = useRef<Editor | 'close' | 'reopen' | null>(null);

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

  const emit = (patch: Partial<EventDetails>) =>
    onChange?.({
      title,
      date: isoOf(date),
      start: hhmm(time.start),
      end: hhmm(time.end),
      description: runs.current,
      ...patch,
    });

  /** After a keyboard save or cancel, the focus goes back to the value. */
  const refocus = (target: Editor, byKeyboard: boolean) => {
    if (byKeyboard && !demoActing.current) focusNext.current = target;
  };
  useLayoutEffect(() => {
    const target = focusNext.current;
    focusNext.current = null;
    if (!target) return;
    const buttons = {
      title: titleValue,
      date: dateValue,
      time: timeValue,
      close: closeButton,
      reopen: reopenButton,
    };
    buttons[target].current?.focus();
  });

  const openEditor = (editor: Editor) => {
    if (editor === 'title') setTitleDraft(title);
    if (editor === 'date') setDateDraft(date);
    if (editor === 'time') setTimeDraft(draftOf(time.start, time.end));
    setOpen({ editor, byReader: !demoActing.current });
  };

  /**
   * A reader who steps in while the demo is editing (a press elsewhere saves) drops the demo's
   * draft instead of saving it.
   */
  const interrupted = () => {
    if (open?.byReader || demoActing.current) return false;
    setOpen(null);
    return true;
  };

  const saveTitle = (byKeyboard: boolean) => {
    if (interrupted()) return;
    const next = titleDraft.trim().replace(/\s+/g, ' ');
    if (next && next !== title) {
      setTitle(next);
      emit({ title: next });
      announce(`Title changed to ${next}.`);
    } else announce(`Title unchanged: ${title}.`);
    setOpen(null);
    refocus('title', byKeyboard);
  };

  const saveTime = (byKeyboard: boolean) => {
    if (interrupted()) return;
    let start = parseClock(timeDraft.start.time, timeDraft.start.meridiem) ?? time.start;
    let end = parseClock(timeDraft.end.time, timeDraft.end.meridiem) ?? time.end;
    // An end before the start moves to an hour after it.
    if (end <= start) end = Math.min(start + 60, 24 * 60 - 1);
    if (end <= start) start = end - 60;
    setTime({ start, end });
    emit({ start: hhmm(start), end: hhmm(end) });
    announce(`Time set: ${spokenTime(start, end)}.`);
    setOpen(null);
    refocus('time', byKeyboard);
  };

  const applyDate = (day: Day, byKeyboard: boolean) => {
    setDate(day);
    emit({ date: isoOf(day) });
    announce(`Date set: ${longDate(day)}.`);
    setOpen(null);
    refocus('date', byKeyboard);
  };

  const cancel = (editor: Editor, byKeyboard: boolean) => {
    announce('Edit cancelled.');
    setOpen(null);
    refocus(editor, byKeyboard);
  };

  const closeCard = () => {
    setOpen(null);
    setClosed(true);
    onClose?.();
    focusNext.current = 'reopen';
  };
  const reopenCard = () => {
    setClosed(false);
    focusNext.current = 'close';
  };

  // The demo, a round at a time; the next round puts everything back.
  const readState = useEffectEvent(() => ({ open, title, date, time, closed }));
  const demo = autoplay && !defaultState && playing;
  useEffect(() => {
    const stage = root.current;
    const ghost = pointer.current;
    if (!demo || !stage || !ghost) return;
    const first = readState();
    const stop = runDemo(stage, ghost, async (steps) => {
      const { wait, moveTo, press } = steps;
      /** Presses `element` as the demo: no announcement, no focus moved. */
      const act = (element: Element | null | undefined) =>
        press(() => {
          demoActing.current = true;
          try {
            tap(element);
          } finally {
            demoActing.current = false;
          }
        });
      for (let round = 0; ; round += 1) {
        const back = round % 2 === 1;
        await wait(round ? 1300 : 800);
        if (readState().closed) {
          await moveTo(reopenButton.current, { shape: 'hand' });
          await act(reopenButton.current);
          await wait(700);
        }
        // Anything a reader left open is saved first.
        const left = readState().open;
        if (left) {
          if (left.editor === 'date') {
            await moveTo(datePopover.current?.querySelector('[data-action="apply"]'), {
              shape: 'hand',
            });
            await act(datePopover.current?.querySelector('[data-action="apply"]'));
          } else {
            const save =
              left.editor === 'title'
                ? titleSave.current
                : timeBox.current?.querySelector('[data-flip="save"]');
            await moveTo(save, { shape: 'hand' });
            await act(save);
          }
          await wait(700);
        }

        // The title: a new name, typed over the old one.
        await moveTo(titleValue.current, { shape: 'hand', at: { x: 0.7, y: 0.55 } });
        setTitleDemo('hover');
        await wait(550);
        await act(titleValue.current);
        setTitleDemo('selected');
        // The pointer steps aside, below the field.
        await moveTo(titleSave.current, { shape: 'arrow', at: { x: 0.1, y: 1.25 }, ms: 260 });
        await wait(600);
        const name = back ? first.title : 'Design Review';
        for (let length = 1; length <= name.length; length += 1) {
          setTitleDraft(name.slice(0, length));
          setTitleDemo('typing');
          await wait(length === 1 ? 180 : 65 + ((length * 37) % 50));
        }
        await wait(380);
        await moveTo(titleSave.current, { shape: 'hand' });
        await act(titleSave.current);
        setTitleDemo(null);
        await wait(700);

        // The description: a phrase highlighted (or cleared), another in bold (or not).
        const handle = description.current;
        if (handle) {
          await formatPhrase(
            steps,
            act,
            handle,
            DEMO_HIGHLIGHT,
            'highlight',
            back ? 'none' : 'yellow',
          );
          await formatPhrase(steps, act, handle, DEMO_BOLD, 'bold', null);
        }

        // The date: four days on, then back.
        await moveTo(dateValue.current, { shape: 'hand', at: { x: 0.6, y: 0.55 } });
        await act(dateValue.current);
        await wait(750);
        const from = readState().date;
        const target = back ? first.date : addDays(from, 4);
        const cell =
          datePopover.current?.querySelector(`[data-iso="${isoOf(target)}"]`) ??
          datePopover.current?.querySelector(`[data-iso="${isoOf(addDays(from, -4))}"]`);
        if (cell) {
          await moveTo(cell, { shape: 'hand' });
          await act(cell);
          await wait(500);
        }
        const apply = datePopover.current?.querySelector('[data-action="apply"]');
        await moveTo(apply, { shape: 'hand' });
        await act(apply);
        await wait(800);

        // The start time: an hour later, typed in, then back.
        await moveTo(timeValue.current, { shape: 'hand', at: { x: 0.25, y: 0.55 } });
        await act(timeValue.current);
        await wait(700);
        const startField = timeBox.current?.querySelector('[data-flip="start-time"]');
        await moveTo(startField, { shape: 'text', at: { x: 0.4, y: 0.5 } });
        await wait(250);
        const hour = Math.floor((back ? first.time.start : readState().time.start + 60) / 60);
        const typed = `${hour % 12 || 12}:00`;
        for (let length = 1; length <= typed.length; length += 1) {
          const text = typed.slice(0, length);
          setTimeDraft((now) => ({ ...now, start: { ...now.start, time: text } }));
          await wait(length === 1 ? 160 : 90);
        }
        await wait(350);
        const saveTimeButton = timeBox.current?.querySelector('[data-flip="save"]');
        await moveTo(saveTimeButton, { shape: 'hand' });
        await act(saveTimeButton);
        await wait(400);
        await moveTo(
          { x: stage.offsetWidth * 0.82, y: stage.offsetHeight * 0.95 },
          { shape: 'arrow' },
        );
      }
    });
    return () => {
      stop();
      setTitleDemo(null);
      description.current?.showSelection(null);
      // What the demo left open goes, unsaved.
      setOpen((now) => (now?.byReader ? now : null));
    };
  }, [demo, root]);

  const editor = open?.editor ?? null;
  const focusOnOpen = !!open?.byReader && !open.quiet;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      style={{ '--spring': MORPH.easing, '--spring-ms': `${MORPH.duration}ms` } as CSSProperties}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
    >
      <div className={styles.stage}>
        <section
          className={styles.card}
          data-part="card"
          aria-labelledby={headingId}
          data-closed={closed || undefined}
          inert={closed}
        >
          <header className={styles.header}>
            <h2 id={headingId} className={styles.heading} data-part="heading">
              Setup events
            </h2>
            <button
              ref={closeButton}
              type="button"
              className={styles.close}
              data-part="close"
              aria-label="Close"
              onClick={closeCard}
            >
              <CloseIcon />
            </button>
          </header>
          <div className={styles.panel} data-part="panel">
            <div className={styles.row} data-part="title-row">
              <span className={styles.label} data-part="label">
                <TitleIcon className={styles.icon} />
                Title
              </span>
              <TitleField
                value={title}
                editing={editor === 'title'}
                draft={titleDraft}
                demo={titleDemo}
                focusOnOpen={focusOnOpen}
                motion={MORPH}
                onEdit={() => openEditor('title')}
                onDraft={setTitleDraft}
                onSave={saveTitle}
                onCancel={(byKeyboard) => cancel('title', byKeyboard)}
                valueRef={titleValue}
                saveRef={titleSave}
              />
            </div>
            <div className={styles.row} data-part="date-row">
              <span className={styles.label}>
                <DateIcon className={styles.icon} />
                Date
              </span>
              <div className={styles.valueBox}>
                <button
                  ref={dateValue}
                  type="button"
                  className={styles.valueButton}
                  data-part="date-value"
                  data-open={editor === 'date' || undefined}
                  aria-label={`Date: ${longDate(date)}. Change`}
                  aria-expanded={editor === 'date'}
                  aria-haspopup="dialog"
                  onClick={() => (editor === 'date' ? cancel('date', false) : openEditor('date'))}
                >
                  <span className={styles.shape} aria-hidden="true" />
                  <span className={styles.valueLine}>
                    <span className={styles.valueText}>{shortDate(date)}</span>
                  </span>
                  <PencilIcon className={styles.pencil} />
                </button>
                <div ref={datePopover} className={styles.anchor}>
                  <DatePicker
                    open={editor === 'date'}
                    anchor={dateValue}
                    draft={dateDraft}
                    focusOnOpen={focusOnOpen}
                    onPick={setDateDraft}
                    onApply={applyDate}
                    onCancel={(byKeyboard) => cancel('date', byKeyboard)}
                    onMonth={(label) => announce(label)}
                  />
                </div>
              </div>
            </div>
            <div ref={timeBox} className={styles.row} data-part="time-row">
              <span className={styles.label} data-editing={editor === 'time' || undefined}>
                <TimeIcon className={styles.icon} />
                <span className={styles.labelText}>Time</span>
              </span>
              <TimeField
                start={time.start}
                end={time.end}
                editing={editor === 'time'}
                draft={timeDraft}
                focusOnOpen={focusOnOpen}
                motion={MORPH}
                onEdit={() => openEditor('time')}
                onDraft={setTimeDraft}
                onSave={saveTime}
                onCancel={() => cancel('time', true)}
                valueRef={timeValue}
              />
            </div>
            <div className={styles.descriptionRow}>
              <span className={styles.label} id={descriptionLabel}>
                <DescriptionIcon className={styles.icon} />
                Description
              </span>
            </div>
            <Description
              ref={description}
              labelId={descriptionLabel}
              initial={initialRuns}
              defaultSelection={defaultState?.selection}
              defaultMenu={defaultState?.menu}
              motion={MORPH}
              announce={announce}
              onChange={(next) => {
                runs.current = next;
                emit({ description: next });
              }}
            />
          </div>
        </section>
        <button
          ref={reopenButton}
          type="button"
          className={styles.reopen}
          data-part="reopen"
          data-shown={closed || undefined}
          inert={!closed}
          onClick={reopenCard}
        >
          <DateIcon className={styles.icon} />
          Setup events
        </button>
      </div>
      <GhostPointer ref={pointer} />
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### event-setup/event-setup.module.css

```css
/* Event setup (event-setup.tsx): a white stage with the card on it, set low enough to leave room
   above it for the calendar, which opens upward; at night a near-black stage, the card's frame, its
   panel and the calendar each a step lighter. The value ↔ field morphs are played from the
   component (flip.ts); this sheet gives each state its layout. Its colours are tokens on the root,
   which the calendar's and the description's sheets use too. */

.root {
  /* Its colours, light and dark: each light-dark() pair resolves by the color-scheme the root
     inherits from the page, or the one the colorScheme prop sets (below). */
  /* The stage; the card's panel; its frame; the fields' grey shape (flip.ts reads it). */
  --surface: #fff;
  --surface-2: #fff;
  --rim: #f7f7f7;
  --field: #f6f6f6;
  /* The heading and the caret; the text; the labels; their icons; the pencil; a field's buttons,
     under the pointer; AM/PM, under the pointer; the description's placeholder. */
  --ink: #141414;
  --text: #3a3a3a;
  --ink-muted: #7c7c7c;
  --icon: #a8a8a8;
  --pencil: #9a9a9a;
  --field-icon: #9d9d9d;
  --field-icon-hover: #4a4a4a;
  --meridiem: #a3a3a3;
  --meridiem-hover: #555;
  --placeholder: #b2b2b2;
  /* The close button's disc and cross, and the disc under the pointer. */
  --close: #b4b4b4;
  --close-cross: #fff;
  --close-hover: #999;
  /* The stage's edge; the card's and the reopen button's; the panel's. */
  --line: rgb(0 0 0 / 0.06);
  --card-edge: rgb(0 0 0 / 0.045);
  --panel-edge: rgb(0 0 0 / 0.035);
  /* A field's button under the pointer. */
  --hover: rgb(0 0 0 / 0.05);
  /* The focus ring; the picked day's square and Apply, under the pointer, and what sits on them. */
  --focus: #3a5cf5;
  --accent: #3a5cf5;
  --accent-hover: #2f50e6;
  --accent-ink: #fff;
  /* A selection, in the fields and the description. */
  --selection: #b5d3f8;
  /* The calendar: its card and edge, text, the month's pill, weekdays, other months' days, a day or
     arrow under the pointer, the arrows, and Cancel with its edge and hover. */
  --popover: #fff;
  --popover-edge: rgb(0 0 0 / 0.05);
  --popover-ink: #333;
  --month: #f5f5f5;
  --month-ink: #2b2b2b;
  --weekday: #aaa;
  --outside: #d0d0d0;
  --day-hover: #f4f4f4;
  --turn: #8c8c8c;
  --turn-hover: #444;
  --cancel: #fff;
  --cancel-hover: #f7f7f7;
  --cancel-edge: rgb(0 0 0 / 0.08);
  /* The format toolbar, dark in both schemes, a step lighter at night with a faint edge. */
  --bar: #222;
  --bar-edge: transparent;
  /* The description's link, then its text colours and highlights (rich-text.ts writes these). */
  --link: #2f55e4;
  --text-red: #d93d33;
  --text-orange: #d4700f;
  --text-green: #21894a;
  --text-blue: #2f55e4;
  --text-purple: #7c45cf;
  --mark-red: #fde0dd;
  --mark-yellow: #fdf0a6;
  --mark-green: #d6f2df;
  --mark-blue: #d9e4fd;
  --mark-purple: #ece2fc;
  /* The demo's I-beam gets a white edge at night, as the system's has. */
  --beam-edge: transparent;
  /* Shades: the card's, the reopen button's and the calendar's drops; those right under them;
     the panel's; the toolbar's. */
  --shadow: rgb(0 0 0 / 0.13);
  --shadow-reopen: rgb(0 0 0 / 0.14);
  --shadow-popover: rgb(0 0 0 / 0.16);
  --shadow-near: rgb(0 0 0 / 0.04);
  --shadow-cancel: rgb(0 0 0 / 0.05);
  --shadow-panel: rgb(0 0 0 / 0.035);
  --shadow-bar: rgb(0 0 0 / 0.32);
  --shadow-bar-near: rgb(0 0 0 / 0.18);
  position: relative;
  container-type: inline-size;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--text);
  font:
    400 14px/20px Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#fff, #111113);
    --surface-2: light-dark(#fff, #202022);
    --rim: light-dark(#f7f7f7, #19191b);
    --field: light-dark(#f6f6f6, #2c2c2e);
    --ink: light-dark(#141414, #f2f2f2);
    --text: light-dark(#3a3a3a, #d6d6d8);
    --ink-muted: light-dark(#7c7c7c, #8e8e93);
    --icon: light-dark(#a8a8a8, #77777c);
    --pencil: light-dark(#9a9a9a, #8e8e93);
    --field-icon: light-dark(#9d9d9d, #8e8e93);
    --field-icon-hover: light-dark(#4a4a4a, #e5e5ea);
    --meridiem: light-dark(#a3a3a3, #8e8e93);
    --meridiem-hover: light-dark(#555, #d1d1d6);
    --placeholder: light-dark(#b2b2b2, #6e6e73);
    --close: light-dark(#b4b4b4, #707075);
    --close-cross: light-dark(#fff, #19191b);
    --close-hover: light-dark(#999, #8e8e93);
    --line: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.07));
    --card-edge: light-dark(rgb(0 0 0 / 0.045), rgb(255 255 255 / 0.08));
    --panel-edge: light-dark(rgb(0 0 0 / 0.035), rgb(255 255 255 / 0.06));
    --hover: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.08));
    --focus: light-dark(#3a5cf5, #7b93ff);
    --accent: light-dark(#3a5cf5, #4766f5);
    --accent-hover: light-dark(#2f50e6, #5a77f7);
    --accent-ink: light-dark(#fff, #fff);
    --selection: light-dark(#b5d3f8, #284878);
    --popover: light-dark(#fff, #2a2a2c);
    --popover-edge: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.1));
    --popover-ink: light-dark(#333, #e5e5ea);
    --month: light-dark(#f5f5f5, #343437);
    --month-ink: light-dark(#2b2b2b, #f2f2f2);
    --weekday: light-dark(#aaa, #98989d);
    --outside: light-dark(#d0d0d0, #5a5a5e);
    --day-hover: light-dark(#f4f4f4, #3a3a3c);
    --turn: light-dark(#8c8c8c, #98989d);
    --turn-hover: light-dark(#444, #e5e5ea);
    --cancel: light-dark(#fff, #3a3a3c);
    --cancel-hover: light-dark(#f7f7f7, #444447);
    --cancel-edge: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.08));
    --bar: light-dark(#222, #303033);
    --bar-edge: light-dark(transparent, rgb(255 255 255 / 0.1));
    --link: light-dark(#2f55e4, #7b9bff);
    --text-red: light-dark(#d93d33, #ff7b72);
    --text-orange: light-dark(#d4700f, #f5a04a);
    --text-green: light-dark(#21894a, #4fc27b);
    --text-blue: light-dark(#2f55e4, #7b9bff);
    --text-purple: light-dark(#7c45cf, #b892f5);
    --mark-red: light-dark(#fde0dd, #552421);
    --mark-yellow: light-dark(#fdf0a6, #4a3f10);
    --mark-green: light-dark(#d6f2df, #1b4229);
    --mark-blue: light-dark(#d9e4fd, #1f3263);
    --mark-purple: light-dark(#ece2fc, #3e2b66);
    --beam-edge: light-dark(transparent, #fff);
    --shadow: light-dark(rgb(0 0 0 / 0.13), rgb(0 0 0 / 0.55));
    --shadow-reopen: light-dark(rgb(0 0 0 / 0.14), rgb(0 0 0 / 0.55));
    --shadow-popover: light-dark(rgb(0 0 0 / 0.16), rgb(0 0 0 / 0.6));
    --shadow-near: light-dark(rgb(0 0 0 / 0.04), rgb(0 0 0 / 0.3));
    --shadow-cancel: light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.3));
    --shadow-panel: light-dark(rgb(0 0 0 / 0.035), rgb(0 0 0 / 0.3));
    --shadow-bar: light-dark(rgb(0 0 0 / 0.32), rgb(0 0 0 / 0.6));
    --shadow-bar-near: light-dark(rgb(0 0 0 / 0.18), rgb(0 0 0 / 0.4));
  }
}

/* The colorScheme prop. Without it the root sets nothing and inherits the page's color-scheme. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

.stage {
  position: relative;
  display: grid;
  justify-items: center;
  padding: 210px 16px 50px;
}

/* The card: a grey frame holding the header and a white panel. */
.card {
  position: relative;
  box-sizing: border-box;
  width: min(392px, 100%);
  padding: 0 4px 4px;
  border-radius: 16px;
  background: var(--rim);
  box-shadow:
    0 0 0 1px var(--card-edge),
    0 1px 2px var(--shadow-near),
    0 10px 28px -8px var(--shadow);
  transition:
    opacity 0.2s ease,
    transform 0.3s ease;
}
.card[data-closed] {
  opacity: 0;
  transform: translateY(8px) scale(0.97);
}

.header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 42px;
  padding: 0 9px 0 16px;
}
.heading {
  margin: 0;
  font-size: 13px;
  font-weight: 700;
  line-height: 20px;
  color: var(--ink);
}
.close {
  display: grid;
  place-items: center;
  width: 28px;
  height: 28px;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: none;
  color: var(--close);
  cursor: pointer;
  transition: color 0.15s ease;
}
.close svg {
  width: 18px;
  height: 18px;
}

.panel {
  position: relative;
  padding: 13px 14px 12px;
  border-radius: 12px;
  background: var(--surface-2);
  box-shadow:
    0 0 0 1px var(--panel-edge),
    0 1px 2px var(--shadow-panel);
}

/* A row: its icon and label on the left, its value on the right. */
.row {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  height: 36px;
}
.row + .row {
  margin-top: 8px;
}
.label {
  display: inline-flex;
  flex: none;
  align-items: center;
  gap: 10px;
  color: var(--ink-muted);
  white-space: nowrap;
}
.icon {
  flex: none;
  width: 18px;
  height: 18px;
  color: var(--icon);
}
.descriptionRow {
  display: flex;
  align-items: center;
  height: 20px;
  margin-top: 8px;
}

/* The values. Each is a button whose text makes room for a pencil under a pointer: the button
   grows to the left, or a long value ends sooner in its ellipsis. */
.valueBox {
  position: relative;
  display: flex;
  justify-content: flex-end;
  min-width: 0;
}
.valueButton {
  position: relative;
  display: flex;
  align-items: center;
  min-width: 0;
  max-width: 100%;
  height: 36px;
  margin: 0;
  padding: 0 12px;
  border: 0;
  border-radius: 10px;
  background: none;
  color: inherit;
  font: inherit;
  white-space: nowrap;
  cursor: pointer;
  transition: padding-right 0.3s var(--spring);
}
.valueButton:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: -2px;
}
.valueLine {
  position: relative;
  z-index: 1;
  display: block;
  max-width: 100%;
  white-space: pre;
}
/* A long value ends in an ellipsis. */
.valueText {
  display: inline-block;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  vertical-align: top;
}
.pencil {
  position: absolute;
  z-index: 1;
  top: 50%;
  right: 12px;
  width: 14px;
  height: 14px;
  margin-top: -7px;
  color: var(--pencil);
  opacity: 0;
  transform: translateX(-6px) scale(0.7);
  transition:
    opacity 0.18s ease,
    transform 0.3s var(--spring);
}
@media (hover: hover) {
  .valueButton:hover {
    padding-right: 32px;
  }
  .valueButton:hover .pencil {
    opacity: 1;
    transform: none;
  }
  .close:hover {
    color: var(--close-hover);
  }
}
.valueButton[data-hover] {
  padding-right: 32px;
}
.valueButton[data-hover] .pencil {
  opacity: 1;
  transform: none;
}

/* The grey shape behind a field: in a value it waits unseen, the morphs stretch it in and out. */
.shape {
  position: absolute;
  z-index: 0;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  border-radius: 10px;
  pointer-events: none;
}
/* The date, while its calendar is open. */
.valueButton[data-open] .shape {
  background: var(--field);
  transition: background-color 0.15s ease;
}
.valueButton[data-open] {
  padding-right: 12px;
}
.valueButton[data-open] .pencil {
  opacity: 0;
}

/* The title's field: a pill around the text, the check and the cross inside it. */
.titleEditor {
  position: relative;
  display: flex;
  align-items: center;
  min-width: 0;
  height: 36px;
  padding: 0 5px 0 11px;
}
.titleEditor > .shape {
  background: var(--field);
}
/* The field grows with its text, up to the room the row has; past that, the field scrolls. */
.titleGrow {
  position: relative;
  z-index: 1;
  display: inline-grid;
  flex: 0 1 auto;
  grid-template-columns: minmax(0, auto);
  min-width: 2px;
  margin-right: 5px;
  overflow: hidden;
}
.titleMirror,
.titleInput {
  grid-area: 1 / 1;
  font: inherit;
  white-space: pre;
}
/* Sizes the field to its text; shows the demo's selection and caret. */
.titleMirror {
  color: transparent;
  border-radius: 2px;
}
.titleInput {
  width: 100%;
  min-width: 0;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 0;
  background: none;
  color: var(--text);
  outline: none;
  appearance: none;
}
.titleInput::selection {
  background: var(--selection);
}
.titleGrow[data-demo='selected'] .titleMirror {
  background: var(--selection);
}
.titleGrow[data-demo='typing'] .titleMirror::after {
  content: '';
  display: inline-block;
  width: 1.5px;
  height: 17px;
  margin-right: -1.5px;
  vertical-align: -3px;
  background: var(--ink);
}

.fieldButton {
  position: relative;
  z-index: 1;
  display: grid;
  flex: none;
  place-items: center;
  width: 28px;
  height: 28px;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 8px;
  background: none;
  color: var(--field-icon);
  cursor: pointer;
  transition:
    color 0.15s ease,
    background-color 0.15s ease;
}
.fieldButton + .fieldButton {
  margin-left: 2px;
}
.fieldButton svg {
  width: 20px;
  height: 20px;
}
@media (hover: hover) {
  .fieldButton:hover {
    color: var(--field-icon-hover);
    background: var(--hover);
  }
}
.fieldButton:focus-visible,
.close:focus-visible,
.meridiem:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 0;
}

/* The time's fields: one grey pill per end, its AM/PM inside, then the check. */
.timeText {
  position: relative;
  display: inline-block;
}
.timeText > .shape {
  z-index: -1;
}
.timeEditor {
  display: flex;
  align-items: center;
  gap: 16px;
  padding-right: 6px;
}
.timePill {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: space-between;
  box-sizing: border-box;
  width: 95px;
  height: 32px;
  padding: 0 13px 0 14px;
}
.timePill > .shape {
  border-radius: 9px;
  background: var(--field);
}
.timeInput {
  position: relative;
  z-index: 1;
  width: 42px;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 0;
  background: none;
  color: var(--text);
  font: inherit;
  outline: none;
  appearance: none;
}
.timeInput::selection {
  background: var(--selection);
}
.meridiem {
  position: relative;
  z-index: 1;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 4px;
  background: none;
  color: var(--meridiem);
  font: inherit;
  cursor: pointer;
}
@media (hover: hover) {
  .meridiem:hover {
    color: var(--meridiem-hover);
  }
}
.timeEditor .fieldButton {
  margin-left: -2px;
}

/* The calendar hangs off the date's value. */
.anchor {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

/* The button that brings a closed card back. */
.reopen {
  position: absolute;
  top: 210px;
  left: 50%;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  height: 40px;
  margin: 0;
  padding: 0 16px 0 12px;
  border: 0;
  border-radius: 20px;
  background: var(--rim);
  box-shadow:
    0 0 0 1px var(--card-edge),
    0 6px 18px -6px var(--shadow-reopen);
  color: var(--ink);
  font: inherit;
  font-weight: 500;
  cursor: pointer;
  opacity: 0;
  transform: translate(-50%, 6px) scale(0.96);
  transition:
    opacity 0.2s ease,
    transform 0.3s ease;
}
.reopen[data-shown] {
  opacity: 1;
  transform: translateX(-50%);
}
.reopen:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* The demo's pointer: an arrow, a hand over buttons, an I-beam over text. Its tip sits at the
   point it is moved to. */
.ghost {
  position: absolute;
  z-index: 40;
  top: 0;
  left: 0;
  width: 0;
  height: 0;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.3s ease;
}
.ghost svg {
  position: absolute;
  width: 22px;
  height: 22px;
  overflow: visible;
  opacity: 0;
  filter: drop-shadow(0 1px 1.2px rgb(0 0 0 / 0.25));
}
.ghost[data-shape='arrow'] [data-shape-of='arrow'],
.ghost[data-shape='hand'] [data-shape-of='hand'],
.ghost[data-shape='text'] [data-shape-of='text'] {
  opacity: 1;
}
.ghost [data-shape-of='arrow'] {
  top: -2.2px;
  left: -3.3px;
  transform-origin: 3.3px 2.2px;
  fill: #1c1c1e;
  stroke: #fff;
  stroke-width: 1.3;
  stroke-linejoin: round;
}
.ghost [data-shape-of='hand'] {
  top: -1.3px;
  left: -8.4px;
  transform-origin: 8.4px 1.3px;
  fill: #fff;
  stroke: #141414;
  stroke-width: 1.1;
  stroke-linejoin: round;
  stroke-linecap: round;
}
.ghost [data-shape-of='text'] {
  top: -11px;
  left: -11px;
  transform-origin: 11px 11px;
  fill: none;
  stroke: #141414;
  stroke-width: 1.3;
  stroke-linecap: round;
  stroke-linejoin: round;
  paint-order: stroke;
}
/* The I-beam's edge, under it: none in light, white at night. */
.ghost [data-shape-of='text'] [data-edge] {
  stroke: var(--beam-edge);
  stroke-width: 3.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;
}

/* Narrow cards: smaller time fields, and the label keeps only its icon while they are open. */
@container (max-width: 400px) {
  .stage {
    padding-right: 12px;
    padding-left: 12px;
  }
  .timeEditor {
    gap: 6px;
  }
  .timePill {
    width: 80px;
    padding: 0 9px 0 10px;
  }
  .label[data-editing] .labelText {
    display: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .card,
  .close,
  .valueButton,
  .valueButton[data-open] .shape,
  .pencil,
  .fieldButton,
  .reopen,
  .ghost {
    transition: none;
  }
}
```

### event-setup/title-field.tsx

```tsx
'use client';

import {
  useEffect,
  useEffectEvent,
  useLayoutEffect,
  useRef,
  type KeyboardEvent,
  type Ref,
} from 'react';
import { play, snapshot, type Snapshot } from './flip';
import { CheckIcon, CrossIcon, PencilIcon } from './icons';
import type { Spring } from './spring';
import styles from './event-setup.module.css';

/*
 * The title's value, and the field it turns into. Pressed, the text stays put while a grey pill
 * grows around it and the save and cancel buttons pop in after it; saved, the pill folds back into
 * the text (see flip.ts). The field grows with what is typed in it, and keeps the text selected
 * at first so that typing replaces it. Enter, the check or a press anywhere else saves; Escape or
 * the cross cancels.
 */

/** The demo's touches: the pointer over the value, the text selected, or a caret after it. */
export type TitleDemo = 'hover' | 'selected' | 'typing' | null;

const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;

export function TitleField({
  value,
  editing,
  draft,
  demo,
  focusOnOpen,
  motion,
  onEdit,
  onDraft,
  onSave,
  onCancel,
  valueRef,
  saveRef,
}: {
  value: string;
  editing: boolean;
  draft: string;
  demo: TitleDemo;
  /** Opened by the reader: the field takes the focus, its text selected. */
  focusOnOpen: boolean;
  motion: Spring;
  onEdit: () => void;
  onDraft: (text: string) => void;
  /** `byKeyboard`: the focus goes back to the value once it is saved. */
  onSave: (byKeyboard: boolean) => void;
  onCancel: (byKeyboard: boolean) => void;
  valueRef: Ref<HTMLButtonElement>;
  saveRef: Ref<HTMLButtonElement>;
}) {
  const box = useRef<HTMLDivElement>(null);
  const input = useRef<HTMLInputElement>(null);
  const before = useRef<Snapshot | null>(null);
  const running = useRef<Animation[]>([]);

  /** Notes where the pieces are, for the morph that follows the switch. */
  const remember = () => {
    if (box.current && !reducedMotion()) before.current = snapshot(box.current);
  };

  // The switch: the morph, and the focus for a reader who opened the field.
  useLayoutEffect(() => {
    const root = box.current;
    const from = before.current;
    before.current = null;
    if (root && from) {
      running.current.forEach((animation) => animation.cancel());
      running.current = play(root, from, motion);
    }
    if (editing && focusOnOpen) {
      input.current?.focus();
      input.current?.select();
    }
  }, [editing, focusOnOpen, motion]);

  useEffect(() => () => running.current.forEach((animation) => animation.cancel()), []);

  // A press anywhere else saves.
  const pressedOutside = useEffectEvent((event: PointerEvent) => {
    if (box.current?.contains(event.target as Node)) return;
    remember();
    onSave(false);
  });
  useEffect(() => {
    if (!editing) return;
    const listener = (event: PointerEvent) => pressedOutside(event);
    document.addEventListener('pointerdown', listener, true);
    return () => document.removeEventListener('pointerdown', listener, true);
  }, [editing]);

  const save = (byKeyboard: boolean) => {
    remember();
    onSave(byKeyboard);
  };
  const cancel = (byKeyboard: boolean) => {
    remember();
    onCancel(byKeyboard);
  };

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    if (event.key === 'Enter') {
      event.preventDefault();
      save(true);
    } else if (event.key === 'Escape') {
      event.preventDefault();
      event.stopPropagation();
      cancel(true);
    }
  };

  return (
    <div ref={box} className={styles.valueBox}>
      {editing ? (
        <div
          className={styles.titleEditor}
          data-part="title-editor"
          onKeyDown={onKeyDown}
          onBlur={(event) => {
            // Tabbing out of the field saves it.
            const next = event.relatedTarget as Node | null;
            if (next && !event.currentTarget.contains(next)) save(false);
          }}
        >
          <span className={styles.shape} data-flip="shape" data-flip-shape="" aria-hidden="true" />
          <span
            className={styles.titleGrow}
            data-flip="text"
            data-demo={demo === 'selected' || demo === 'typing' ? demo : undefined}
          >
            <span className={styles.titleMirror} aria-hidden="true">
              {draft}
            </span>
            <input
              ref={input}
              className={styles.titleInput}
              value={draft}
              size={1}
              aria-label="Title"
              autoComplete="off"
              spellCheck={false}
              enterKeyHint="done"
              onChange={(event) => onDraft(event.target.value)}
            />
          </span>
          <button
            ref={saveRef}
            type="button"
            className={styles.fieldButton}
            data-part="save"
            data-flip="save"
            data-flip-enter=""
            aria-label="Save the title"
            onClick={(event) => save(event.detail === 0)}
          >
            <CheckIcon />
          </button>
          <button
            type="button"
            className={styles.fieldButton}
            data-flip="cancel"
            data-flip-enter=""
            aria-label="Cancel"
            onClick={(event) => cancel(event.detail === 0)}
          >
            <CrossIcon />
          </button>
        </div>
      ) : (
        <button
          ref={valueRef}
          type="button"
          className={styles.valueButton}
          data-part="title-value"
          data-hover={demo === 'hover' || undefined}
          aria-label={`Title: ${value}. Edit`}
          onClick={() => {
            remember();
            onEdit();
          }}
        >
          <span
            className={styles.shape}
            data-flip="shape"
            data-flip-shape="hidden"
            aria-hidden="true"
          />
          <span className={styles.valueLine}>
            <span className={styles.valueText} data-flip="text">
              {value}
            </span>
          </span>
          <PencilIcon className={styles.pencil} />
        </button>
      )}
    </div>
  );
}
```

### event-setup/time-field.tsx

```tsx
'use client';

import {
  useEffect,
  useEffectEvent,
  useLayoutEffect,
  useRef,
  type KeyboardEvent,
  type Ref,
} from 'react';
import { clockOf, parseClock } from './dates';
import { play, snapshot, type Snapshot } from './flip';
import { CheckIcon, PencilIcon } from './icons';
import type { Spring } from './spring';
import styles from './event-setup.module.css';

/*
 * The time's value, and the two fields it splits into. Pressed, "8:00 AM – 10:00 PM" comes apart:
 * each time glides into a small grey field of its own, the dash fades, and a check pops in at the
 * end (see flip.ts). In a field, digits type a time ("9", "930", "21:30"), the arrow keys move it
 * by a quarter of an hour (Shift: an hour), and the AM/PM button switches its half of the day.
 * Enter, the check or a press anywhere else saves; Escape cancels.
 */

export type Clock = { time: string; meridiem: 'AM' | 'PM' };
export type TimeDraft = { start: Clock; end: Clock };
type Edge = keyof TimeDraft;

const EDGES: { edge: Edge; name: string }[] = [
  { edge: 'start', name: 'Start' },
  { edge: 'end', name: 'End' },
];

const DAY = 24 * 60;
const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;

/** What the two fields show for two times, in minutes after midnight. */
export const draftOf = (start: number, end: number): TimeDraft => ({
  start: clockOf(start),
  end: clockOf(end),
});

export function TimeField({
  start,
  end,
  editing,
  draft,
  focusOnOpen,
  motion,
  onEdit,
  onDraft,
  onSave,
  onCancel,
  valueRef,
}: {
  /** The times, in minutes after midnight. */
  start: number;
  end: number;
  editing: boolean;
  draft: TimeDraft;
  /** Opened by the reader: the start field takes the focus. */
  focusOnOpen: boolean;
  motion: Spring;
  onEdit: () => void;
  onDraft: (draft: TimeDraft) => void;
  onSave: (byKeyboard: boolean) => void;
  onCancel: () => void;
  valueRef: Ref<HTMLButtonElement>;
}) {
  const box = useRef<HTMLDivElement>(null);
  const first = useRef<HTMLInputElement>(null);
  const before = useRef<Snapshot | null>(null);
  const running = useRef<Animation[]>([]);

  const remember = () => {
    if (box.current && !reducedMotion()) before.current = snapshot(box.current);
  };

  useLayoutEffect(() => {
    const root = box.current;
    const from = before.current;
    before.current = null;
    if (root && from) {
      running.current.forEach((animation) => animation.cancel());
      running.current = play(root, from, motion);
    }
    if (editing && focusOnOpen) {
      first.current?.focus();
      first.current?.select();
    }
  }, [editing, focusOnOpen, motion]);

  useEffect(() => () => running.current.forEach((animation) => animation.cancel()), []);

  const pressedOutside = useEffectEvent((event: PointerEvent) => {
    if (box.current?.contains(event.target as Node)) return;
    remember();
    onSave(false);
  });
  useEffect(() => {
    if (!editing) return;
    const listener = (event: PointerEvent) => pressedOutside(event);
    document.addEventListener('pointerdown', listener, true);
    return () => document.removeEventListener('pointerdown', listener, true);
  }, [editing]);

  const save = (byKeyboard: boolean) => {
    remember();
    onSave(byKeyboard);
  };

  /** Moves one time by `minutes`, around the clock. */
  const nudge = (edge: Edge, minutes: number) => {
    const clock = draft[edge];
    const now = parseClock(clock.time, clock.meridiem);
    if (now === null) return;
    const next = (((Math.round(now / 15) * 15 + minutes) % DAY) + DAY) % DAY;
    onDraft({ ...draft, [edge]: clockOf(next) });
  };

  const onKeyDown = (event: KeyboardEvent<HTMLInputElement>, edge: Edge) => {
    if (event.key === 'Enter') {
      event.preventDefault();
      save(true);
    } else if (event.key === 'Escape') {
      event.preventDefault();
      event.stopPropagation();
      remember();
      onCancel();
    } else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
      event.preventDefault();
      nudge(edge, (event.key === 'ArrowUp' ? 1 : -1) * (event.shiftKey ? 60 : 15));
    }
  };

  const shown = { start: clockOf(start), end: clockOf(end) };

  return (
    <div ref={box} className={styles.valueBox}>
      {editing ? (
        <div
          className={styles.timeEditor}
          data-part="time-editor"
          onBlur={(event) => {
            const next = event.relatedTarget as Node | null;
            if (next && !event.currentTarget.contains(next)) save(false);
          }}
        >
          {EDGES.map(({ edge, name }, i) => {
            const clock = draft[edge];
            const other = clock.meridiem === 'AM' ? 'PM' : 'AM';
            return (
              <div key={edge} className={styles.timePill} data-part="time-pill">
                <span
                  className={styles.shape}
                  data-flip={`${edge}-shape`}
                  data-flip-shape=""
                  aria-hidden="true"
                />
                <input
                  ref={i === 0 ? first : undefined}
                  className={styles.timeInput}
                  data-flip={`${edge}-time`}
                  value={clock.time}
                  aria-label={`${name} time, ${clock.meridiem}`}
                  inputMode="numeric"
                  autoComplete="off"
                  spellCheck={false}
                  maxLength={5}
                  onChange={(event) =>
                    onDraft({
                      ...draft,
                      [edge]: { ...clock, time: event.target.value.replace(/[^\d:]/g, '') },
                    })
                  }
                  onKeyDown={(event) => onKeyDown(event, edge)}
                />
                <button
                  type="button"
                  className={styles.meridiem}
                  data-part="meridiem"
                  data-flip={`${edge}-meridiem`}
                  aria-label={`${name}: ${clock.meridiem}. Switch to ${other}`}
                  onClick={() => onDraft({ ...draft, [edge]: { ...clock, meridiem: other } })}
                >
                  {clock.meridiem}
                </button>
              </div>
            );
          })}
          <button
            type="button"
            className={styles.fieldButton}
            data-flip="save"
            data-flip-enter=""
            aria-label="Save the time"
            onClick={(event) => save(event.detail === 0)}
          >
            <CheckIcon />
          </button>
        </div>
      ) : (
        <button
          ref={valueRef}
          type="button"
          className={styles.valueButton}
          data-part="time-value"
          aria-label={`Time: ${shown.start.time} ${shown.start.meridiem} to ${shown.end.time} ${shown.end.meridiem}. Edit`}
          onClick={() => {
            remember();
            onEdit();
          }}
        >
          <span className={styles.valueLine}>
            {EDGES.map(({ edge }, i) => (
              <span key={edge}>
                {i === 1 && (
                  <>
                    {' '}
                    <span className={styles.valueText} data-flip="dash" data-flip-enter="">
                      –
                    </span>{' '}
                  </>
                )}
                <span className={styles.timeText}>
                  <span
                    className={styles.shape}
                    data-flip={`${edge}-shape`}
                    data-flip-shape="hidden"
                    aria-hidden="true"
                  />
                  <span className={styles.valueText} data-flip={`${edge}-time`}>
                    {shown[edge].time}
                  </span>{' '}
                  <span className={styles.valueText} data-flip={`${edge}-meridiem`}>
                    {shown[edge].meridiem}
                  </span>
                </span>
              </span>
            ))}
          </span>
          <PencilIcon className={styles.pencil} />
        </button>
      )}
    </div>
  );
}
```

### event-setup/flip.ts

```tsx
import { easingOf, type Spring } from './spring';

/*
 * The morphs between a value and its editor, played FLIP: before the switch, every piece marked
 * data-flip="key" is measured where it is on screen; after it, where the new layout put the piece
 * with the same key; then each plays from the old place to the new one on a spring. Pieces marked
 * data-flip-shape are the grey shapes behind fields: they stretch from their old box to the new
 * one, and fade in or out when one of the two is a shape that does not show
 * (data-flip-shape="hidden"). A shape moves by its offsets and fades by its colour rather than by
 * a transform and its opacity: that way it stays painted in place and never becomes a layer of
 * its own, which would be redrawn at every size it passes through. A piece marked
 * data-flip-enter that was not there before (a button of the editor, the dash between two times)
 * fades and grows in.
 */

type Piece = { rect: DOMRect; shown: boolean };
export type Snapshot = Map<string, Piece>;

const pieces = (root: HTMLElement) => root.querySelectorAll<HTMLElement>('[data-flip]');

/** Where each piece under `root` is now. */
export function snapshot(root: HTMLElement): Snapshot {
  const map: Snapshot = new Map();
  for (const element of pieces(root)) {
    map.set(element.dataset.flip ?? '', {
      rect: element.getBoundingClientRect(),
      shown: element.dataset.flipShape !== 'hidden',
    });
  }
  return map;
}

/** Plays every piece under `root` from where `before` saw it; returns the animations. */
export function play(root: HTMLElement, before: Snapshot, motion: Spring): Animation[] {
  // Screen pixels to the component's own, should it sit in a scaled container.
  const scale = root.getBoundingClientRect().width / root.offsetWidth || 1;
  const easing = easingOf(motion);
  const animations: Animation[] = [];
  let entering = 0;
  for (const element of pieces(root)) {
    const was = before.get(element.dataset.flip ?? '');
    if (!was) {
      if (element.dataset.flipEnter === undefined) continue;
      animations.push(
        element.animate(
          [
            { opacity: 0, scale: 0.6 },
            { opacity: 1, scale: 1 },
          ],
          {
            duration: motion.duration,
            delay: 40 + entering * 40,
            easing,
            fill: 'backwards',
          },
        ),
      );
      entering += 1;
      continue;
    }
    const now = element.getBoundingClientRect();
    const dx = (was.rect.left - now.left) / scale;
    const dy = (was.rect.top - now.top) / scale;
    if (element.dataset.flipShape === undefined) {
      if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) continue;
      animations.push(
        element.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }], {
          duration: motion.duration,
          easing,
        }),
      );
      continue;
    }
    const shown = element.dataset.flipShape !== 'hidden';
    if (shown !== was.shown) {
      // The fade is quicker than the stretch: the shape is there, or gone, early.
      const fill = getComputedStyle(element).getPropertyValue('--field').trim();
      animations.push(
        element.animate(
          [
            { backgroundColor: was.shown ? fill : 'transparent' },
            { backgroundColor: shown ? fill : 'transparent' },
          ],
          { duration: Math.min(220, motion.duration), easing: 'ease-out', fill: 'backwards' },
        ),
      );
    }
    animations.push(
      element.animate(
        [
          {
            left: `${dx}px`,
            top: `${dy}px`,
            width: `${was.rect.width / scale}px`,
            height: `${was.rect.height / scale}px`,
          },
          {
            left: '0px',
            top: '0px',
            width: `${now.width / scale}px`,
            height: `${now.height / scale}px`,
          },
        ],
        { duration: motion.duration, easing },
      ),
    );
  }
  return animations;
}
```

### event-setup/date-picker.tsx

```tsx
'use client';

import {
  useEffect,
  useEffectEvent,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
  type RefObject,
} from 'react';
import {
  addDays,
  addMonths,
  isoOf,
  longDate,
  MONTHS,
  monthGrid,
  sameDay,
  WEEKDAY_SHORT,
  weekdayOf,
  type Day,
} from './dates';
import { ChevronIcon } from './icons';
import styles from './date-picker.module.css';

/*
 * The date's calendar: a popover that springs open above the value, from its corner. One month
 * at a time, Monday first, with the days of the months around it greyed; the picked day is a
 * blue square that glides from day to day. Cancel or Apply. The days form a grid: the arrow keys
 * move a day or a week, Page Up and Page Down a month (Shift: a year), Home and End to the ends
 * of the week; Space picks a day and Enter picks it and applies.
 */

const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;

export function DatePicker({
  open,
  anchor,
  draft,
  focusOnOpen,
  onPick,
  onApply,
  onCancel,
  onMonth,
}: {
  open: boolean;
  /** The control that opens the calendar: a press on it is not a press outside. */
  anchor: RefObject<HTMLElement | null>;
  /** The day picked so far; the calendar opens on its month. */
  draft: Day;
  /** Opened by the reader: the picked day takes the focus. */
  focusOnOpen: boolean;
  onPick: (day: Day) => void;
  /** Applies `day`; `byKeyboard`: the focus goes back to the value once the calendar closes. */
  onApply: (day: Day, byKeyboard: boolean) => void;
  onCancel: (byKeyboard: boolean) => void;
  /** Called with the month's name when the reader turns the page. */
  onMonth: (label: string) => void;
}) {
  const [view, setView] = useState({ year: draft.year, month: draft.month });
  /** The day that holds the grid's tab stop. */
  const [focus, setFocus] = useState<Day>(draft);
  const [wasOpen, setWasOpen] = useState(open);
  // Each opening starts on the picked day.
  if (open !== wasOpen) {
    setWasOpen(open);
    if (open) {
      setView({ year: draft.year, month: draft.month });
      setFocus(draft);
    }
  }

  const root = useRef<HTMLDivElement>(null);
  const days = useRef<HTMLDivElement>(null);
  /** The focus follows the tab stop after a move with the keyboard. */
  const moveFocus = useRef(false);
  /** The month turned this way, for the slide: -1 back, 1 forward. */
  const turn = useRef(0);

  const weeks = monthGrid(view.year, view.month);
  const title = `${MONTHS[view.month]} ${view.year}`;
  const row = weeks.findIndex((week) => week.some((day) => sameDay(day, draft)));
  const column = row < 0 ? -1 : weeks[row].findIndex((day) => sameDay(day, draft));

  /** Shows `day`'s month, sliding the page the way it went. */
  const showMonth = (day: Day, announce: boolean) => {
    if (day.year === view.year && day.month === view.month) return;
    turn.current = day.year * 12 + day.month > view.year * 12 + view.month ? 1 : -1;
    setView({ year: day.year, month: day.month });
    if (announce) onMonth(`${MONTHS[day.month]} ${day.year}`);
  };

  const pick = (day: Day) => {
    setFocus(day);
    showMonth(day, false);
    onPick(day);
  };

  const onGridKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const moves: Record<string, () => Day> = {
      ArrowLeft: () => addDays(focus, -1),
      ArrowRight: () => addDays(focus, 1),
      ArrowUp: () => addDays(focus, -7),
      ArrowDown: () => addDays(focus, 7),
      Home: () => addDays(focus, -weekdayOf(focus)),
      End: () => addDays(focus, 6 - weekdayOf(focus)),
      PageUp: () => addMonths(focus, event.shiftKey ? -12 : -1),
      PageDown: () => addMonths(focus, event.shiftKey ? 12 : 1),
    };
    if (event.key in moves) {
      event.preventDefault();
      const next = moves[event.key]();
      moveFocus.current = true;
      setFocus(next);
      showMonth(next, true);
    } else if (event.key === ' ' || event.key === 'Enter') {
      event.preventDefault();
      pick(focus);
      if (event.key === 'Enter') onApply(focus, true);
    }
  };

  // The focus: on the picked day when a reader opens the calendar, then with the tab stop.
  useLayoutEffect(() => {
    if (open && focusOnOpen) moveFocus.current = true;
  }, [open, focusOnOpen]);
  useLayoutEffect(() => {
    if (!moveFocus.current || !open) return;
    moveFocus.current = false;
    days.current?.querySelector<HTMLElement>(`[data-iso="${isoOf(focus)}"]`)?.focus();
  });

  // A turned page slides in from the side it comes from.
  useLayoutEffect(() => {
    const direction = turn.current;
    turn.current = 0;
    if (!direction || !days.current || reducedMotion()) return;
    days.current.animate(
      [
        { transform: `translateX(${direction * 14}px)`, opacity: 0 },
        { transform: 'none', opacity: 1 },
      ],
      { duration: 260, easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)' },
    );
  }, [view]);

  // A press outside the calendar and its date cancels.
  const pressedOutside = useEffectEvent((event: PointerEvent) => {
    const target = event.target as Node;
    if (root.current?.contains(target) || anchor.current?.contains(target)) return;
    onCancel(false);
  });
  useEffect(() => {
    if (!open) return;
    const listener = (event: PointerEvent) => pressedOutside(event);
    document.addEventListener('pointerdown', listener, true);
    return () => document.removeEventListener('pointerdown', listener, true);
  }, [open]);

  return (
    <div
      ref={root}
      className={styles.popover}
      data-part="calendar"
      data-open={open || undefined}
      role="dialog"
      aria-label="Choose a date"
      inert={!open}
      onKeyDown={(event) => {
        if (event.key !== 'Escape') return;
        event.preventDefault();
        event.stopPropagation();
        onCancel(true);
      }}
    >
      <div className={styles.header}>
        <button
          type="button"
          className={styles.turn}
          aria-label="Previous month"
          onClick={() => showMonth(addMonths({ ...view, date: 1 }, -1), true)}
        >
          <ChevronIcon back />
        </button>
        <span className={styles.month} data-part="month">
          {title}
        </span>
        <button
          type="button"
          className={styles.turn}
          aria-label="Next month"
          onClick={() => showMonth(addMonths({ ...view, date: 1 }, 1), true)}
        >
          <ChevronIcon />
        </button>
      </div>
      <div role="grid" aria-label={title} onKeyDown={onGridKeyDown}>
        <div className={styles.weekdays} role="row">
          {WEEKDAY_SHORT.map((name) => (
            <span key={name} role="columnheader" className={styles.weekday}>
              {name}
            </span>
          ))}
        </div>
        <div ref={days} className={styles.days}>
          {row >= 0 && (
            <span
              className={styles.picked}
              data-part="picked-day"
              style={{ '--row': row, '--column': column } as CSSProperties}
              aria-hidden="true"
            />
          )}
          {weeks.map((week) => (
            <div key={isoOf(week[0])} className={styles.week} role="row">
              {week.map((day) => {
                const picked = sameDay(day, draft);
                return (
                  <div
                    key={isoOf(day)}
                    role="gridcell"
                    className={styles.day}
                    data-iso={isoOf(day)}
                    data-outside={day.month !== view.month || undefined}
                    data-picked={picked || undefined}
                    aria-selected={picked}
                    aria-label={longDate(day)}
                    tabIndex={sameDay(day, focus) ? 0 : -1}
                    onClick={() => pick(day)}
                  >
                    {day.date}
                  </div>
                );
              })}
            </div>
          ))}
        </div>
      </div>
      <div className={styles.actions}>
        <button
          type="button"
          className={styles.cancel}
          data-part="cancel"
          onClick={(event) => onCancel(event.detail === 0)}
        >
          Cancel
        </button>
        <button
          type="button"
          className={styles.apply}
          data-part="apply"
          data-action="apply"
          onClick={(event) => onApply(draft, event.detail === 0)}
        >
          Apply
        </button>
      </div>
    </div>
  );
}
```

### event-setup/date-picker.module.css

```css
/* The calendar (date-picker.tsx): a white popover above the date's value, right-aligned with it
   (dark grey at night, its colours tokens of the card's root). It grows out of the value's corner
   on a spring and fades away when it closes. */

.popover {
  --cell: 28px;
  position: absolute;
  z-index: 30;
  right: 0;
  bottom: calc(100% + 6px);
  box-sizing: border-box;
  width: calc(7 * var(--cell) + 22px);
  padding: 16px 11px 12px;
  border-radius: 14px;
  background: var(--popover);
  box-shadow:
    0 0 0 1px var(--popover-edge),
    0 2px 5px var(--shadow-near),
    0 16px 36px -8px var(--shadow-popover);
  color: var(--popover-ink);
  font-size: 13px;
  line-height: 16px;
  pointer-events: auto;
  transform-origin: 80% 100%;
  opacity: 0;
  transform: translateY(8px) scale(0.9);
  visibility: hidden;
  transition:
    opacity 0.16s ease,
    transform 0.2s ease,
    visibility 0s linear 0.2s;
}
.popover[data-open] {
  opacity: 1;
  transform: none;
  visibility: visible;
  transition:
    opacity 0.16s ease,
    transform var(--spring-ms) var(--spring),
    visibility 0s;
}

/* The month, between the buttons that turn it. */
.header {
  display: flex;
  align-items: center;
  gap: 4px;
  height: 30px;
  margin-bottom: 8px;
}
.turn {
  display: grid;
  flex: none;
  place-items: center;
  width: 26px;
  height: 26px;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 7px;
  background: none;
  color: var(--turn);
  cursor: pointer;
}
.turn svg {
  width: 16px;
  height: 16px;
}
.month {
  flex: 1;
  height: 26px;
  border-radius: 7px;
  background: var(--month);
  font-weight: 500;
  line-height: 26px;
  text-align: center;
  color: var(--month-ink);
}

.weekdays,
.week {
  display: grid;
  grid-template-columns: repeat(7, var(--cell));
}
.weekdays {
  height: 21px;
  margin-bottom: 5px;
}
.weekday {
  font-size: 9.5px;
  font-weight: 500;
  line-height: 21px;
  letter-spacing: 0.04em;
  text-align: center;
  color: var(--weekday);
}

.days {
  position: relative;
}
.day {
  position: relative;
  z-index: 1;
  height: var(--cell);
  border-radius: 7px;
  font-size: 12.5px;
  line-height: var(--cell);
  text-align: center;
  font-variant-numeric: tabular-nums;
  cursor: pointer;
  outline: none;
  transition: color 0.15s ease;
}
.day[data-outside] {
  color: var(--outside);
}
.day[data-picked] {
  color: var(--accent-ink);
  transition: color 0.2s ease 0.1s;
}
.day:focus-visible {
  box-shadow: inset 0 0 0 2px var(--focus);
}
.day[data-picked]:focus-visible {
  box-shadow:
    inset 0 0 0 2px var(--focus),
    inset 0 0 0 3.5px var(--accent-ink);
}
@media (hover: hover) {
  .day:not([data-picked]):hover {
    background: var(--day-hover);
  }
  .turn:hover {
    background: var(--day-hover);
    color: var(--turn-hover);
  }
}
/* The picked day's square, which glides to the next day picked. */
.picked {
  position: absolute;
  top: 1px;
  left: 1px;
  width: calc(var(--cell) - 2px);
  height: calc(var(--cell) - 2px);
  border-radius: 7px;
  background: var(--accent);
  transform: translate(calc(var(--column) * var(--cell)), calc(var(--row) * var(--cell)));
}
.popover[data-open] .picked {
  transition: transform 0.36s var(--spring);
}

.actions {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 9px;
  margin-top: 7px;
}
.cancel,
.apply {
  height: 28px;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 8px;
  font: inherit;
  font-size: 12.5px;
  font-weight: 500;
  cursor: pointer;
  transition:
    background-color 0.15s ease,
    box-shadow 0.15s ease;
}
.cancel {
  background: var(--cancel);
  box-shadow:
    0 0 0 1px var(--cancel-edge),
    0 1px 2px var(--shadow-cancel);
  color: var(--popover-ink);
}
.apply {
  background: var(--accent);
  color: var(--accent-ink);
}
@media (hover: hover) {
  .cancel:hover {
    background: var(--cancel-hover);
  }
  .apply:hover {
    background: var(--accent-hover);
  }
}
.turn:focus-visible,
.cancel:focus-visible,
.apply:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
  .popover,
  .popover[data-open],
  .popover[data-open] .picked,
  .day,
  .day[data-picked],
  .cancel,
  .apply {
    transition: none;
  }
}
```

### event-setup/dates.ts

```tsx
/*
 * Calendar days and clock times, without time zones: a day is a year, a month (0 to 11) and a
 * date, and arithmetic goes through UTC so that no daylight-saving change can shift it. Weeks
 * start on Monday.
 */

export type Day = { year: number; month: number; date: number };

export const MONTHS = [
  'January',
  'February',
  'March',
  'April',
  'May',
  'June',
  'July',
  'August',
  'September',
  'October',
  'November',
  'December',
];
const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
/** The calendar's column headers. */
export const WEEKDAY_SHORT = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'];

const toUTC = ({ year, month, date }: Day) => new Date(Date.UTC(year, month, date));
const fromUTC = (time: Date): Day => ({
  year: time.getUTCFullYear(),
  month: time.getUTCMonth(),
  date: time.getUTCDate(),
});

/** Reads "2026-04-24"; anything else gives today. */
export function parseDay(iso: string): Day {
  const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
  if (match) return fromUTC(new Date(Date.UTC(+match[1], +match[2] - 1, +match[3])));
  const now = new Date();
  return { year: now.getFullYear(), month: now.getMonth(), date: now.getDate() };
}

export const isoOf = ({ year, month, date }: Day) =>
  `${year}-${String(month + 1).padStart(2, '0')}-${String(date).padStart(2, '0')}`;

/** "Apr 24, 2026". */
export const shortDate = ({ year, month, date }: Day) =>
  `${MONTHS[month].slice(0, 3)} ${date}, ${year}`;

/** "Friday, April 24, 2026". */
export const longDate = (day: Day) =>
  `${WEEKDAYS[weekdayOf(day)]}, ${MONTHS[day.month]} ${day.date}, ${day.year}`;

/** 0 for Monday to 6 for Sunday. */
export const weekdayOf = (day: Day) => (toUTC(day).getUTCDay() + 6) % 7;

export const addDays = (day: Day, days: number) =>
  fromUTC(new Date(toUTC(day).getTime() + days * 86_400_000));

/** The same date `months` later, or the month's last day when it is shorter. */
export function addMonths(day: Day, months: number): Day {
  const first = new Date(Date.UTC(day.year, day.month + months, 1));
  const last = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate();
  return {
    year: first.getUTCFullYear(),
    month: first.getUTCMonth(),
    date: Math.min(day.date, last),
  };
}

export const sameDay = (a: Day, b: Day) =>
  a.year === b.year && a.month === b.month && a.date === b.date;

/** The weeks that show a month, Monday first, with the days of the months around it. */
export function monthGrid(year: number, month: number): Day[][] {
  const first: Day = { year, month, date: 1 };
  let day = addDays(first, -weekdayOf(first));
  const weeks: Day[][] = [];
  do {
    const week: Day[] = [];
    for (let i = 0; i < 7; i += 1) {
      week.push(day);
      day = addDays(day, 1);
    }
    weeks.push(week);
  } while (day.month === month);
  return weeks;
}

/** A time of day in minutes, as "8:00" and "AM". */
export function clockOf(minutes: number) {
  const hours = Math.floor(minutes / 60);
  return {
    time: `${hours % 12 || 12}:${String(minutes % 60).padStart(2, '0')}`,
    meridiem: hours < 12 ? ('AM' as const) : ('PM' as const),
  };
}

/** "8:00 AM". */
export const clockLabel = (minutes: number) => {
  const { time, meridiem } = clockOf(minutes);
  return `${time} ${meridiem}`;
};

/**
 * Reads a typed time: "8", "8:30", "830" or "20:15". Hours from 1 to 12 take `meridiem`; 0 and 13
 * to 23 are read on a 24-hour clock. Gives null for anything else.
 */
export function parseClock(text: string, meridiem: 'AM' | 'PM'): number | null {
  const match = /^\s*(\d{1,2})(?::?(\d{2}))?\s*$/.exec(text);
  if (!match) return null;
  const hours = +match[1];
  const minutes = match[2] ? +match[2] : 0;
  if (hours > 23 || minutes > 59) return null;
  if (hours === 0 || hours > 12) return hours * 60 + minutes;
  return ((hours % 12) + (meridiem === 'PM' ? 12 : 0)) * 60 + minutes;
}
```

### event-setup/description.tsx

```tsx
'use client';

import {
  useEffect,
  useEffectEvent,
  useImperativeHandle,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
  type Ref,
} from 'react';
import { FormatToolbar, type Toggle } from './format-toolbar';
import {
  format,
  fromDOM,
  HIGHLIGHTS,
  marksOf,
  rangeOf,
  replace,
  select,
  selectedSpan,
  serialize,
  TEXT_COLORS,
  textOf,
  toHTML,
  type Marks,
  type Run,
  type Span,
  type Swatch,
} from './rich-text';
import { easingOf, type Spring } from './spring';
import styles from './description.module.css';

/*
 * The description: a paragraph edited in place. It is a contentEditable element whose content
 * React leaves alone after the first render. The browser types into it; after each change the
 * runs are read back from the DOM (rich-text.ts), and written afresh when the browser left
 * anything of its own behind. Formats are applied to the runs, which are then written out again
 * and the selection put back by its character offsets. Enter, the browser's own formatting and
 * rich pastes are turned away (a paste comes in as plain text), and undo works on the runs.
 *
 * A selection brings the toolbar up above it, centred on it; it glides when the selection
 * changes. The demo draws a selection of its own instead of taking the page's.
 */

export type DescriptionHandle = {
  /** Shows the demo's selection over `span`, without touching the page's own; null clears it. */
  showSelection: (span: Span | null) => void;
  /** Where the characters of `span` are on screen. */
  rectsOf: (span: Span) => DOMRect[];
  text: () => string;
  /** The toolbar, for the demo to find its buttons. */
  toolbar: () => HTMLElement | null;
};

type Box = { left: number; top: number; width: number; height: number };

const MOD = (event: KeyboardEvent) => event.metaKey || event.ctrlKey;
const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;

/** The words for a format, in an announcement. */
const NAMES: Record<Toggle, string> = { bold: 'Bold', italic: 'Italic', strike: 'Strikethrough' };

/** The custom highlight that paints a selection drawn by hand (see description.module.css). */
const PAINTED = 'event-setup-selection';

/** The browser's own formatting commands, which the runs take instead. */
const COMMANDS: Record<string, Toggle> = {
  formatBold: 'bold',
  formatItalic: 'italic',
  formatStrikeThrough: 'strike',
};

/** The keyboard shortcuts, with Cmd on a Mac and Ctrl elsewhere. */
const SHORTCUTS: Record<string, Toggle | 'undo' | 'redo'> = {
  b: 'bold',
  i: 'italic',
  'shift+x': 'strike',
  z: 'undo',
  'shift+z': 'redo',
  y: 'redo',
};

export function Description({
  ref,
  labelId,
  initial,
  defaultSelection,
  defaultMenu,
  motion,
  announce,
  onChange,
}: {
  ref: Ref<DescriptionHandle>;
  /** The id of the row's label, which names the text box. */
  labelId: string;
  initial: Run[];
  /** A phrase shown selected from the start, with the toolbar over it, until a press elsewhere. */
  defaultSelection?: string;
  /** With defaultSelection: the toolbar's menu open from the start. */
  defaultMenu?: 'color' | 'highlight' | 'link';
  motion: Spring;
  /** Tells screen readers what a format did. */
  announce: (text: string) => void;
  onChange?: (runs: Run[]) => void;
}) {
  const [runs, setRuns] = useState(initial);
  const [html] = useState(() => toHTML(initial));
  /** The reader's selection in the text, when it is not empty. */
  const [span, setSpan] = useState<Span | null>(null);
  const [demoSpan, setDemoSpan] = useState<Span | null>(() => {
    const at = defaultSelection ? textOf(initial).indexOf(defaultSelection) : -1;
    return at < 0 ? null : { start: at, end: at + defaultSelection!.length };
  });
  /** The selection given by defaultSelection is still shown: a press off the toolbar ends it. */
  const [givenSelection, setGivenSelection] = useState(demoSpan !== null);
  /** Where the focus is: in the text, in the toolbar, or elsewhere. */
  const [focusIn, setFocusIn] = useState<'text' | 'toolbar' | null>(null);
  const [place, setPlace] = useState({ x: 0, y: 0 });
  /** The selection drawn by hand: the demo's, or the reader's while they work the toolbar. */
  const [boxes, setBoxes] = useState<Box[]>([]);
  const [color, setColor] = useState<Swatch>(TEXT_COLORS[0]);
  const [highlight, setHighlight] = useState<Swatch>(HIGHLIGHTS[1]);

  const wrapper = useRef<HTMLDivElement>(null);
  const editor = useRef<HTMLDivElement>(null);
  const toolbar = useRef<HTMLDivElement>(null);
  const current = useRef(initial);
  const history = useRef<{ past: Run[][]; future: Run[][]; typedAt: number }>({
    past: [],
    future: [],
    typedAt: 0,
  });
  /** Where the toolbar stands while it shows, for its glide to the next place. */
  const placed = useRef<{ x: number; y: number } | null>(null);

  const active = demoSpan ?? span;
  const shown = !!active && (!!demoSpan || focusIn !== null);
  const drawn = demoSpan ?? (focusIn === 'toolbar' ? span : null);
  const marks: Marks = active ? marksOf(runs, active) : {};

  /** Takes in a new version of the text: state, history and the page that holds it. */
  const take = (next: Run[], typing: boolean) => {
    const memory = history.current;
    const now = Date.now();
    // A burst of typing makes one step of undo.
    if (!typing || now - memory.typedAt > 900) {
      memory.past.push(current.current);
      if (memory.past.length > 100) memory.past.shift();
    }
    memory.typedAt = typing ? now : 0;
    memory.future = [];
    current.current = next;
    setRuns(next);
    onChange?.(next);
  };

  /** Writes `next` into the page and selects `keep` again. */
  const write = (next: Run[], keep: Span | null) => {
    const element = editor.current;
    if (!element) return;
    element.innerHTML = toHTML(next);
    if (keep && document.activeElement === element) select(element, keep);
  };

  /** After the browser typed: reads the runs back, and tidies the DOM if it needs it. */
  const sync = () => {
    const element = editor.current;
    if (!element) return;
    const next = fromDOM(element);
    const clean = toHTML(next);
    if (serialize(element) !== clean) write(next, selectedSpan(element));
    take(next, true);
  };

  const apply = (patch: Marks, message: string) => {
    if (!active) return;
    const next = format(current.current, active, patch);
    take(next, false);
    write(next, active);
    announce(message);
  };

  const toggle = (mark: Toggle) => {
    if (!active) return;
    const on = !marksOf(current.current, active)[mark];
    apply({ [mark]: on }, `${NAMES[mark]} ${on ? 'on' : 'off'}.`);
  };

  /** Steps back through the versions of the text, or forward again. */
  const undo = (back: boolean) => {
    const memory = history.current;
    const element = editor.current;
    const next = (back ? memory.past : memory.future).pop();
    if (!next || !element) return;
    (back ? memory.future : memory.past).push(current.current);
    memory.typedAt = 0;
    current.current = next;
    setRuns(next);
    onChange?.(next);
    const end = textOf(next).length;
    const keep = selectedSpan(element);
    write(next, keep && { start: Math.min(keep.start, end), end: Math.min(keep.end, end) });
  };

  /** Back from the toolbar to the text, with the selection it was working on. */
  const backToText = () => {
    const element = editor.current;
    if (!element) return;
    element.focus();
    if (active) select(element, active);
  };

  // The browser's own edits that the runs do not take: line breaks, its formatting commands,
  // rich pastes and its undo, which no longer knows the DOM once the runs have been written.
  const beforeInput = useEffectEvent((event: InputEvent) => {
    const element = editor.current;
    if (!element) return;
    const type = event.inputType;
    if (type === 'insertParagraph' || type === 'insertLineBreak') event.preventDefault();
    else if (type === 'historyUndo' || type === 'historyRedo') {
      event.preventDefault();
      undo(type === 'historyUndo');
    } else if (type.startsWith('format')) {
      event.preventDefault();
      if (COMMANDS[type]) toggle(COMMANDS[type]);
    } else if (type === 'insertFromPaste' || type === 'insertFromDrop') {
      event.preventDefault();
      const text = (event.dataTransfer?.getData('text/plain') ?? '').replace(/\s+/g, ' ');
      const at = selectedSpan(element);
      if (!text || !at) return;
      const next = replace(current.current, at, text);
      take(next, false);
      const caret = at.start + text.length;
      write(next, { start: caret, end: caret });
    }
  });
  useEffect(() => {
    const element = editor.current;
    if (!element) return;
    const listener = (event: InputEvent) => beforeInput(event);
    element.addEventListener('beforeinput', listener);
    return () => element.removeEventListener('beforeinput', listener);
  }, []);

  // The reader's selection, while it is in the text. Working in the toolbar keeps it.
  const selectionChanged = useEffectEvent(() => {
    const element = editor.current;
    if (!element || toolbar.current?.contains(document.activeElement)) return;
    const next = selectedSpan(element);
    const kept = next && next.end > next.start ? next : null;
    setSpan((now) =>
      now && kept && now.start === kept.start && now.end === kept.end ? now : kept,
    );
  });
  useEffect(() => {
    const listener = () => selectionChanged();
    document.addEventListener('selectionchange', listener);
    return () => document.removeEventListener('selectionchange', listener);
  }, []);

  // The toolbar's place: centred over the selection, above its first line, inside the card. From
  // one selection to the next it glides; where it first shows, it fades in.
  useLayoutEffect(() => {
    const element = editor.current;
    const frame = wrapper.current;
    const bar = toolbar.current;
    const was = placed.current;
    placed.current = null;
    if (!shown || !active || !element || !frame || !bar) return;
    const rects = Array.from(rangeOf(element, active).getClientRects()).filter(
      (rect) => rect.width > 0.5,
    );
    if (!rects.length) return;
    const box = frame.getBoundingClientRect();
    const scale = box.width / frame.offsetWidth || 1;
    const left = Math.min(...rects.map((rect) => rect.left));
    const right = Math.max(...rects.map((rect) => rect.right));
    const top = Math.min(...rects.map((rect) => rect.top));
    const middle = ((left + right) / 2 - box.left) / scale;
    const x = Math.max(
      -6,
      Math.min(frame.offsetWidth - bar.offsetWidth + 6, middle - bar.offsetWidth / 2),
    );
    const y = (top - box.top) / scale - bar.offsetHeight - 12;
    placed.current = { x, y };
    setPlace((now) => (now.x === x && now.y === y ? now : { x, y }));
    if (!was || (was.x === x && was.y === y) || reducedMotion()) return;
    bar.animate(
      [
        { transform: `translate(${was.x}px, ${was.y}px)` },
        { transform: `translate(${x}px, ${y}px)` },
      ],
      { duration: motion.duration, easing: easingOf(motion) },
    );
  }, [active, runs, shown, motion]);

  // The selection drawn by hand: painted as the browser paints its own, with a custom highlight,
  // or else with a box behind each line.
  useLayoutEffect(() => {
    const element = editor.current;
    const frame = wrapper.current;
    if (!drawn || !element || !frame) {
      setBoxes((now) => (now.length ? [] : now));
      return;
    }
    const range = rangeOf(element, drawn);
    if (typeof Highlight !== 'undefined' && 'highlights' in CSS) {
      const highlight = CSS.highlights.get(PAINTED) ?? new Highlight();
      CSS.highlights.set(PAINTED, highlight);
      highlight.add(range);
      return () => {
        highlight.delete(range);
      };
    }
    const box = frame.getBoundingClientRect();
    const scale = box.width / frame.offsetWidth || 1;
    setBoxes(
      Array.from(range.getClientRects())
        .filter((rect) => rect.width > 0.5)
        .map((rect) => ({
          left: (rect.left - box.left) / scale,
          top: (rect.top - box.top) / scale,
          width: rect.width / scale,
          height: rect.height / scale,
        })),
    );
  }, [drawn, runs]);

  // The selection given by defaultSelection ends at a press anywhere but the toolbar.
  useEffect(() => {
    if (!givenSelection) return;
    const listener = (event: PointerEvent) => {
      if (toolbar.current?.contains(event.target as Node)) return;
      setGivenSelection(false);
      setDemoSpan(null);
    };
    document.addEventListener('pointerdown', listener, true);
    return () => document.removeEventListener('pointerdown', listener, true);
  }, [givenSelection]);

  useImperativeHandle(ref, () => ({
    showSelection: setDemoSpan,
    rectsOf: (target) =>
      editor.current ? Array.from(rangeOf(editor.current, target).getClientRects()) : [],
    text: () => textOf(current.current),
    toolbar: () => toolbar.current,
  }));

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const command = MOD(event)
      ? SHORTCUTS[`${event.shiftKey ? 'shift+' : ''}${event.key.toLowerCase()}`]
      : undefined;
    if (command === 'undo' || command === 'redo') {
      event.preventDefault();
      undo(command === 'undo');
    } else if (command) {
      event.preventDefault();
      toggle(command);
    } else if (event.key === 'Escape' && span) {
      // Leaves the toolbar: the selection folds to its end.
      event.preventDefault();
      event.stopPropagation();
      document.getSelection()?.collapseToEnd();
    }
  };

  return (
    <div
      ref={wrapper}
      className={styles.description}
      onFocus={(event) => setFocusIn(toolbar.current?.contains(event.target) ? 'toolbar' : 'text')}
      onBlur={(event) => {
        if (!event.currentTarget.contains(event.relatedTarget)) setFocusIn(null);
      }}
    >
      <div
        className={styles.paper}
        data-part="description"
        data-active={focusIn || demoSpan ? '' : undefined}
      >
        {boxes.map((box, i) => (
          <span
            key={i}
            className={styles.selection}
            style={
              {
                '--left': `${box.left}px`,
                '--top': `${box.top}px`,
                '--width': `${box.width}px`,
                '--height': `${box.height}px`,
              } as CSSProperties
            }
            aria-hidden="true"
          />
        ))}
        <div
          ref={editor}
          className={styles.editor}
          contentEditable
          role="textbox"
          aria-multiline="true"
          aria-labelledby={labelId}
          data-placeholder="Add a description"
          spellCheck={false}
          dangerouslySetInnerHTML={{ __html: html }}
          onInput={(event) => {
            if (!(event.nativeEvent as InputEvent).isComposing) sync();
          }}
          onCompositionEnd={sync}
          onKeyDown={onKeyDown}
        />
      </div>
      <FormatToolbar
        ref={toolbar}
        shown={shown}
        defaultMenu={givenSelection ? defaultMenu : undefined}
        x={place.x}
        y={place.y}
        marks={marks}
        color={color}
        highlight={highlight}
        onToggle={toggle}
        onColor={(swatch) => {
          setColor(swatch);
          apply({ color: swatch.id }, `Text colour: ${swatch.name}.`);
        }}
        onHighlight={(swatch) => {
          if (swatch.id) setHighlight(swatch);
          apply({ highlight: swatch.id }, `Highlight: ${swatch.name}.`);
        }}
        onLink={(href) => {
          apply({ href: href ?? undefined }, href ? 'Link added.' : 'Link removed.');
          backToText();
        }}
        onLeave={backToText}
      />
    </div>
  );
}
```

### event-setup/format-toolbar.tsx

```tsx
'use client';

import {
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
  type Ref,
} from 'react';
import { CheckIcon, LinkIcon } from './icons';
import { HIGHLIGHTS, TEXT_COLORS, type Marks, type Swatch } from './rich-text';
import styles from './description.module.css';

/*
 * The formatting toolbar that floats over a selection: text colour, highlight, link, bold,
 * italic and strikethrough. The two colours open a row of swatches above the toolbar; the link
 * turns the toolbar into an address field. It never takes the focus from the text on a press, so
 * the selection stays; from the keyboard it is a toolbar (Tab from the text reaches it, the arrow
 * keys move along it, Escape goes back to the text).
 */

export type Toggle = 'bold' | 'italic' | 'strike';
type Menu = 'color' | 'highlight' | 'link' | null;

const TOGGLES: { mark: Toggle; label: string; glyph: string }[] = [
  { mark: 'bold', label: 'Bold', glyph: 'B' },
  { mark: 'italic', label: 'Italic', glyph: 'I' },
  { mark: 'strike', label: 'Strikethrough', glyph: 'S' },
];

/** Moves the focus along a row of buttons with the arrow keys, Home and End. */
function rove(event: KeyboardEvent<HTMLElement>) {
  const keys = ['ArrowLeft', 'ArrowRight', 'Home', 'End'];
  if (!keys.includes(event.key)) return;
  const buttons = Array.from(event.currentTarget.querySelectorAll<HTMLElement>('button'));
  const at = buttons.indexOf(document.activeElement as HTMLElement);
  if (at < 0) return;
  event.preventDefault();
  const next =
    event.key === 'Home'
      ? 0
      : event.key === 'End'
        ? buttons.length - 1
        : (at + (event.key === 'ArrowRight' ? 1 : -1) + buttons.length) % buttons.length;
  buttons.forEach((button, i) => (button.tabIndex = i === next ? 0 : -1));
  buttons[next].focus();
}

export function FormatToolbar({
  ref,
  shown,
  defaultMenu,
  x,
  y,
  marks,
  color,
  highlight,
  onToggle,
  onColor,
  onHighlight,
  onLink,
  onLeave,
}: {
  ref: Ref<HTMLDivElement>;
  shown: boolean;
  /** A menu open from the start: the text colours, the highlights or the link field. */
  defaultMenu?: Menu;
  /** Where the toolbar's top left corner goes, in the description's pixels. */
  x: number;
  y: number;
  /** What the selected text has in common: the toggles show it pressed. */
  marks: Marks;
  /** The last colours picked, shown in the toolbar. */
  color: Swatch;
  highlight: Swatch;
  onToggle: (mark: Toggle) => void;
  onColor: (swatch: Swatch) => void;
  onHighlight: (swatch: Swatch) => void;
  /** Sets the selection's link, or removes it with null. */
  onLink: (href: string | null) => void;
  /** Escape: back to the text. */
  onLeave: () => void;
}) {
  const [menu, setMenu] = useState<Menu>(() => (shown ? (defaultMenu ?? null) : null));
  const [address, setAddress] = useState('');
  const [wasShown, setWasShown] = useState(shown);
  if (shown !== wasShown) {
    setWasShown(shown);
    if (!shown) setMenu(null);
  }
  const bar = useRef<HTMLDivElement>(null);
  /** Where the focus goes once a menu opens or closes. */
  const focusNext = useRef<string | null>(null);

  useLayoutEffect(() => {
    const target = focusNext.current;
    focusNext.current = null;
    if (target) bar.current?.querySelector<HTMLElement>(target)?.focus();
  }, [menu]);

  /** Opens a row of colours, or the address field (which always takes the focus). */
  const openMenu = (next: Menu, byKeyboard: boolean) => {
    if (next === 'link') {
      focusNext.current = 'input';
      setAddress(marks.href ?? '');
    } else if (byKeyboard) focusNext.current = '[data-current]';
    setMenu((now) => (now === next ? null : next));
  };
  const closeMenu = (trigger: string) => {
    focusNext.current = `[data-tool="${trigger}"]`;
    setMenu(null);
  };

  const swatches = menu === 'color' ? TEXT_COLORS : menu === 'highlight' ? HIGHLIGHTS : null;
  const current = menu === 'color' ? (marks.color ?? '') : (marks.highlight ?? '');

  return (
    <div
      ref={ref}
      className={styles.toolbar}
      data-part="toolbar"
      data-shown={shown || undefined}
      style={{ transform: `translate(${x}px, ${y}px)` }}
      inert={!shown}
      // A press here keeps the selection in the text (the address field excepted).
      onPointerDown={(event) => {
        if (!(event.target instanceof HTMLInputElement)) event.preventDefault();
      }}
      onKeyDown={(event) => {
        if (event.key !== 'Escape') return;
        event.preventDefault();
        event.stopPropagation();
        if (menu === 'link') closeMenu('link');
        else if (menu) closeMenu(menu);
        else onLeave();
      }}
    >
      <div ref={bar} className={styles.bar}>
        {swatches && (
          <div
            className={styles.swatches}
            data-part="swatches"
            data-kind={menu}
            role="group"
            aria-label={menu === 'color' ? 'Text colour' : 'Highlight'}
            onKeyDown={rove}
          >
            {swatches.map((swatch) => {
              const on = swatch.id === current;
              return (
                <button
                  key={swatch.id || 'none'}
                  type="button"
                  className={styles.swatch}
                  data-swatch={swatch.id || (menu === 'color' ? 'default' : 'none')}
                  data-current={on || undefined}
                  aria-pressed={on}
                  aria-label={swatch.name}
                  tabIndex={on ? 0 : -1}
                  onClick={(event) => {
                    if (menu === 'color') onColor(swatch);
                    else if (menu === 'highlight') onHighlight(swatch);
                    if (event.detail === 0 && menu) closeMenu(menu);
                    else setMenu(null);
                  }}
                >
                  <span style={{ '--swatch': swatch.swatch } as CSSProperties} />
                </button>
              );
            })}
          </div>
        )}
        {menu === 'link' ? (
          <form
            className={styles.row}
            data-part="link-field"
            onSubmit={(event) => {
              event.preventDefault();
              const text = address.trim();
              if (text) onLink(/^[a-z][\w+.-]*:/i.test(text) ? text : `https://${text}`);
              setMenu(null);
            }}
          >
            <LinkIcon className={styles.linkGlyph} />
            <input
              className={styles.address}
              value={address}
              placeholder="Paste a link"
              aria-label="Link address"
              inputMode="url"
              autoComplete="off"
              spellCheck={false}
              onChange={(event) => setAddress(event.target.value)}
            />
            <button type="submit" className={styles.tool} aria-label="Add the link">
              <CheckIcon />
            </button>
          </form>
        ) : (
          <div
            className={styles.row}
            data-part="tools"
            role="toolbar"
            aria-label="Format the text"
            onKeyDown={rove}
          >
            <button
              type="button"
              className={`${styles.tool} ${styles.labelled}`}
              data-tool="color"
              aria-expanded={menu === 'color'}
              aria-label={`Text colour: ${color.name}`}
              onClick={(event) => openMenu('color', event.detail === 0)}
            >
              Text
              <span
                className={styles.dot}
                style={{ '--swatch': color.swatch } as CSSProperties}
                aria-hidden="true"
              />
            </button>
            <button
              type="button"
              className={`${styles.tool} ${styles.labelled}`}
              data-tool="highlight"
              aria-expanded={menu === 'highlight'}
              aria-label={`Highlight: ${highlight.name}`}
              tabIndex={-1}
              onClick={(event) => openMenu('highlight', event.detail === 0)}
            >
              Highlight
              <span
                className={styles.square}
                style={{ '--swatch': highlight.swatch } as CSSProperties}
                aria-hidden="true"
              />
            </button>
            <span className={styles.divider} aria-hidden="true" />
            <button
              type="button"
              className={styles.tool}
              data-tool="link"
              aria-pressed={!!marks.href}
              aria-label={marks.href ? 'Remove the link' : 'Link'}
              tabIndex={-1}
              onClick={(event) => {
                if (marks.href) onLink(null);
                else openMenu('link', event.detail === 0);
              }}
            >
              <LinkIcon />
            </button>
            <span className={styles.divider} aria-hidden="true" />
            {TOGGLES.map(({ mark, label, glyph }) => (
              <button
                key={mark}
                type="button"
                className={`${styles.tool} ${styles.glyph}`}
                data-tool={mark}
                data-mark={mark}
                aria-pressed={!!marks[mark]}
                aria-label={label}
                tabIndex={-1}
                onClick={() => onToggle(mark)}
              >
                {glyph}
              </button>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}
```

### event-setup/description.module.css

```css
/* The description (description.tsx) and the toolbar that floats over its selection
   (format-toolbar.tsx). The text sits on a sheet that turns grey while it is being edited. The
   colours are tokens of the card's root; the toolbar is dark in both schemes. */

.description {
  position: relative;
  margin-top: 6px;
}
.paper {
  position: relative;
  padding: 6px 10px;
  border-radius: 10px;
  transition: background-color 0.2s ease;
}
.paper[data-active] {
  background: var(--field);
}

.editor {
  position: relative;
  z-index: 1;
  min-height: 20px;
  color: var(--text);
  white-space: pre-wrap;
  overflow-wrap: break-word;
  outline: none;
  caret-color: var(--ink);
  cursor: text;
}
.editor::selection,
.editor *::selection {
  background-color: var(--selection);
}
/* A rule of its own: a browser that doesn't know ::highlight() drops the whole selector list. */
.editor::highlight(event-setup-selection),
.editor *::highlight(event-setup-selection) {
  background-color: var(--selection);
}
.editor:empty::before {
  content: attr(data-placeholder);
  color: var(--placeholder);
}
.editor strong {
  font-weight: 700;
}
.editor s {
  text-decoration-thickness: 1px;
}
.editor mark {
  padding: 1px 0;
  border-radius: 3px;
  color: inherit;
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
}
.editor a {
  color: var(--link);
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 2px;
}

/* A selection drawn by hand, where the browser has no custom highlights: a box behind each line. */
.selection {
  position: absolute;
  z-index: 0;
  top: var(--top);
  left: var(--left);
  width: var(--width);
  height: var(--height);
  background: var(--selection);
  pointer-events: none;
}

/* The toolbar: placed by a transform; it fades and rises in, and glides between selections. */
.toolbar {
  position: absolute;
  z-index: 25;
  top: 0;
  left: 0;
  pointer-events: none;
}
.bar {
  position: relative;
  opacity: 0;
  transform: translateY(4px) scale(0.97);
  transform-origin: 50% 100%;
  transition:
    opacity 0.14s ease,
    transform 0.2s ease;
}
.toolbar[data-shown] .bar {
  opacity: 1;
  transform: none;
  pointer-events: auto;
  transition:
    opacity 0.16s ease,
    transform var(--spring-ms) var(--spring);
}
.row {
  display: flex;
  align-items: center;
  gap: 2px;
  box-sizing: border-box;
  min-width: 280px;
  height: 40px;
  margin: 0;
  padding: 0 6.5px 0 5px;
  border-radius: 9px;
  background: var(--bar);
  box-shadow:
    0 0 0 1px var(--bar-edge),
    0 1px 2px var(--shadow-bar-near),
    0 8px 20px -6px var(--shadow-bar);
  color: #fff;
}

.tool {
  display: inline-flex;
  flex: none;
  align-items: center;
  justify-content: center;
  gap: 5px;
  min-width: 28px;
  height: 30px;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 6px;
  background: none;
  color: #fff;
  font: inherit;
  font-size: 12px;
  font-weight: 500;
  line-height: 1;
  cursor: pointer;
  transition: background-color 0.12s ease;
}
.tool svg {
  width: 15px;
  height: 15px;
}
.labelled {
  padding: 0 8px;
  color: #f2f2f2;
}
.labelled + .labelled {
  padding-right: 6px;
}
.glyph {
  min-width: 25px;
  font-size: 13px;
}
.glyph + .glyph {
  margin-left: 3px;
}
.glyph[data-mark='bold'] {
  font-weight: 700;
}
.glyph[data-mark='italic'] {
  font-style: italic;
}
.glyph[data-mark='strike'] {
  text-decoration: line-through;
  text-decoration-thickness: 1.2px;
}
@media (hover: hover) {
  .tool:hover {
    background: rgb(255 255 255 / 0.1);
  }
}
.tool[aria-pressed='true'],
.tool[aria-expanded='true'] {
  background: rgb(255 255 255 / 0.17);
}
.tool:focus-visible,
.swatch:focus-visible {
  outline: 2px solid #8eabff;
  outline-offset: -2px;
}
.dot,
.square {
  flex: none;
  width: 9px;
  height: 9px;
  background: var(--swatch);
}
.dot {
  border-radius: 50%;
}
.square {
  border-radius: 2px;
}
.divider {
  flex: none;
  width: 1px;
  height: 16px;
  margin: 0 2px;
  background: rgb(255 255 255 / 0.18);
}

/* The colours, in a row above the toolbar, under the button that opened them. */
.swatches {
  position: absolute;
  bottom: calc(100% + 6px);
  left: 0;
  display: flex;
  gap: 2px;
  padding: 4px;
  border-radius: 9px;
  background: var(--bar);
  box-shadow:
    0 0 0 1px var(--bar-edge),
    0 1px 2px var(--shadow-bar-near),
    0 8px 20px -6px var(--shadow-bar);
  animation: rise 0.22s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.swatches[data-kind='highlight'] {
  left: 56px;
}
@keyframes rise {
  from {
    opacity: 0;
    transform: translateY(4px) scale(0.96);
  }
}
.swatch {
  display: grid;
  place-items: center;
  width: 28px;
  height: 28px;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 6px;
  background: none;
  cursor: pointer;
}
.swatch span {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: var(--swatch);
  box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.12);
}
.swatches[data-kind='highlight'] .swatch span {
  border-radius: 3px;
}
.swatch[data-swatch='none'] span {
  background: linear-gradient(
    to top right,
    transparent calc(50% - 0.75px),
    #ec5a51 calc(50% - 0.75px) calc(50% + 0.75px),
    transparent calc(50% + 0.75px)
  );
  box-shadow: inset 0 0 0 1.5px rgb(255 255 255 / 0.45);
}
@media (hover: hover) {
  .swatch:hover {
    background: rgb(255 255 255 / 0.1);
  }
}
.swatch[aria-pressed='true'] span {
  box-shadow:
    0 0 0 2px var(--bar),
    0 0 0 3.5px #fff;
}

/* The link's address field, in place of the buttons. */
.linkGlyph {
  flex: none;
  width: 15px;
  height: 15px;
  margin-left: 6px;
  color: rgb(255 255 255 / 0.6);
}
.address {
  flex: 1;
  min-width: 0;
  height: 30px;
  margin: 0;
  padding: 0 6px;
  border: 0;
  background: none;
  color: #fff;
  font: inherit;
  font-size: 12.5px;
  outline: none;
}
.address::placeholder {
  color: rgb(255 255 255 / 0.45);
}

@media (prefers-reduced-motion: reduce) {
  .paper,
  .bar,
  .toolbar[data-shown] .bar,
  .tool {
    transition: none;
  }
  .swatches {
    animation: none;
  }
}
```

### event-setup/rich-text.ts

```tsx
/*
 * The description's text model. A paragraph is a list of runs: pieces of text that share the same
 * marks (bold, italic, strikethrough, a text colour, a highlight, a link). The editor writes the
 * runs as HTML, reads them back from the DOM after the browser has typed into it, and formats a
 * selection by cutting the runs at its edges and changing the marks of the runs in between.
 * Positions are character offsets into the paragraph, so a selection survives a fresh render.
 */

export type Marks = {
  bold?: boolean;
  italic?: boolean;
  strike?: boolean;
  /** The id of one of TEXT_COLORS. */
  color?: string;
  /** The id of one of HIGHLIGHTS. */
  highlight?: string;
  /** A link's address. */
  href?: string;
};

export type Run = { text: string; marks: Marks };

/** A stretch of the paragraph, in characters: from `start` up to, not including, `end`. */
export type Span = { start: number; end: number };

/**
 * A colour to pick: its name, the colour of its swatch, and the colour it gives the text. The text
 * colours and highlights are tokens of the card's stylesheet, light or dark with its scheme; the
 * swatches sit on the dark toolbar in both.
 */
export type Swatch = { id: string; name: string; swatch: string; value: string };

/** Text colours. The first, with an empty id, takes the colour back to the paragraph's own. */
export const TEXT_COLORS: Swatch[] = [
  { id: '', name: 'Default', swatch: '#a3a3a3', value: 'inherit' },
  { id: 'red', name: 'Red', swatch: '#ec5a51', value: 'var(--text-red)' },
  { id: 'orange', name: 'Orange', swatch: '#f08c2e', value: 'var(--text-orange)' },
  { id: 'green', name: 'Green', swatch: '#3fb26b', value: 'var(--text-green)' },
  { id: 'blue', name: 'Blue', swatch: '#4f72f7', value: 'var(--text-blue)' },
  { id: 'purple', name: 'Purple', swatch: '#9d6ae6', value: 'var(--text-purple)' },
];

/** Highlights: a soft fill behind the text. The first, with an empty id, removes it. */
export const HIGHLIGHTS: Swatch[] = [
  { id: '', name: 'None', swatch: 'transparent', value: 'transparent' },
  { id: 'red', name: 'Red', swatch: '#f1a9a6', value: 'var(--mark-red)' },
  { id: 'yellow', name: 'Yellow', swatch: '#f3d35f', value: 'var(--mark-yellow)' },
  { id: 'green', name: 'Green', swatch: '#8fd3a5', value: 'var(--mark-green)' },
  { id: 'blue', name: 'Blue', swatch: '#9bb6f5', value: 'var(--mark-blue)' },
  { id: 'purple', name: 'Purple', swatch: '#c3a6f0', value: 'var(--mark-purple)' },
];

/** The marks with nothing switched off left in them, so that two equal sets compare equal. */
function clean(marks: Marks): Marks {
  const out: Marks = {};
  if (marks.bold) out.bold = true;
  if (marks.italic) out.italic = true;
  if (marks.strike) out.strike = true;
  if (marks.color) out.color = marks.color;
  if (marks.highlight) out.highlight = marks.highlight;
  if (marks.href) out.href = marks.href;
  return out;
}

const same = (a: Marks, b: Marks) =>
  !!a.bold === !!b.bold &&
  !!a.italic === !!b.italic &&
  !!a.strike === !!b.strike &&
  (a.color ?? '') === (b.color ?? '') &&
  (a.highlight ?? '') === (b.highlight ?? '') &&
  (a.href ?? '') === (b.href ?? '');

/** Drops empty runs and joins neighbours that look the same. */
export function normalize(runs: Run[]): Run[] {
  const out: Run[] = [];
  for (const run of runs) {
    if (!run.text) continue;
    const last = out[out.length - 1];
    if (last && same(last.marks, run.marks)) last.text += run.text;
    else out.push({ text: run.text, marks: clean(run.marks) });
  }
  return out;
}

export const textOf = (runs: Run[]) => runs.map((run) => run.text).join('');

/** The runs, with one cut at `offset` so that a run starts there. */
function cut(runs: Run[], offset: number): Run[] {
  const out: Run[] = [];
  let at = 0;
  for (const run of runs) {
    const end = at + run.text.length;
    if (offset > at && offset < end) {
      out.push({ text: run.text.slice(0, offset - at), marks: run.marks });
      out.push({ text: run.text.slice(offset - at), marks: run.marks });
    } else out.push(run);
    at = end;
  }
  return out;
}

/** Sets marks on the characters of `span`; a mark set to false or undefined comes off. */
export function format(runs: Run[], span: Span, patch: Marks): Run[] {
  let at = 0;
  const out = cut(cut(runs, span.start), span.end).map((run) => {
    const start = at;
    at += run.text.length;
    const inside = start >= span.start && at <= span.end;
    return inside ? { text: run.text, marks: { ...run.marks, ...patch } } : run;
  });
  return normalize(out);
}

/**
 * What the characters of `span` share: a mark counts only if every one of them has it. A collapsed
 * span reads the character before it, which is what typing there would continue.
 */
export function marksOf(runs: Run[], span: Span): Marks {
  const from = span.start === span.end ? Math.max(0, span.start - 1) : span.start;
  const to = span.start === span.end ? from + 1 : span.end;
  let at = 0;
  let common: Marks | null = null;
  for (const run of runs) {
    const start = at;
    at += run.text.length;
    if (at <= from || start >= to) continue;
    const marks: Marks = run.marks;
    common = common
      ? clean({
          bold: common.bold && marks.bold,
          italic: common.italic && marks.italic,
          strike: common.strike && marks.strike,
          color: common.color === marks.color ? common.color : undefined,
          highlight: common.highlight === marks.highlight ? common.highlight : undefined,
          href: common.href === marks.href ? common.href : undefined,
        })
      : clean(marks);
  }
  return common ?? {};
}

/** Puts plain `text` in place of `span`, in the marks of the text it follows. */
export function replace(runs: Run[], span: Span, text: string): Run[] {
  const before: Run[] = [];
  const after: Run[] = [];
  let at = 0;
  for (const run of cut(cut(runs, span.start), span.end)) {
    const start = at;
    at += run.text.length;
    if (at <= span.start) before.push(run);
    else if (start >= span.end) after.push(run);
  }
  const marks = before.length ? before[before.length - 1].marks : (after[0]?.marks ?? {});
  return normalize([...before, { text, marks }, ...after]);
}

const escapeText = (text: string) =>
  text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const escapeAttribute = (text: string) => escapeText(text).replace(/"/g, '&quot;');

/** The tags that wrap one run's text, outermost first. */
function wrap(text: string, marks: Marks): string {
  let html = text;
  if (marks.bold) html = `<strong>${html}</strong>`;
  if (marks.italic) html = `<em>${html}</em>`;
  if (marks.strike) html = `<s>${html}</s>`;
  const color = TEXT_COLORS.find((swatch) => swatch.id && swatch.id === marks.color);
  if (color) html = `<span data-color="${color.id}" style="color:${color.value}">${html}</span>`;
  const highlight = HIGHLIGHTS.find((swatch) => swatch.id && swatch.id === marks.highlight);
  if (highlight) {
    html = `<mark data-highlight="${highlight.id}" style="background-color:${highlight.value}">${html}</mark>`;
  }
  if (marks.href) html = `<a href="${escapeAttribute(marks.href)}">${html}</a>`;
  return html;
}

/** The paragraph's HTML. */
export const toHTML = (runs: Run[]) =>
  runs.map((run) => wrap(escapeText(run.text), run.marks)).join('');

/** Reads the runs back from the editor's DOM, after the browser has typed into it. */
export function fromDOM(root: HTMLElement): Run[] {
  const runs: Run[] = [];
  const walk = (node: Node, marks: Marks) => {
    if (node.nodeType === Node.TEXT_NODE) {
      runs.push({ text: node.textContent ?? '', marks });
      return;
    }
    if (!(node instanceof HTMLElement)) return;
    const next: Marks = { ...marks };
    const tag = node.tagName;
    if (tag === 'STRONG' || tag === 'B') next.bold = true;
    if (tag === 'EM' || tag === 'I') next.italic = true;
    if (tag === 'S' || tag === 'STRIKE' || tag === 'DEL') next.strike = true;
    if (tag === 'A' && node.getAttribute('href')) next.href = node.getAttribute('href') ?? '';
    if (node.dataset.color) next.color = node.dataset.color;
    if (node.dataset.highlight) next.highlight = node.dataset.highlight;
    node.childNodes.forEach((child) => walk(child, next));
  };
  root.childNodes.forEach((child) => walk(child, {}));
  return normalize(runs);
}

/**
 * The editor's DOM written the way toHTML writes it. When it differs from toHTML(fromDOM(root)),
 * the browser has left something of its own behind (a <br>, a styled <span>, two runs that should
 * be one), and the editor renders its runs afresh.
 */
export function serialize(root: Node): string {
  let html = '';
  root.childNodes.forEach((node) => {
    if (node.nodeType === Node.TEXT_NODE) {
      html += escapeText(node.textContent ?? '');
      return;
    }
    if (!(node instanceof HTMLElement)) return;
    const inner = serialize(node);
    const tag = node.tagName;
    if (tag === 'STRONG' || tag === 'EM' || tag === 'S') {
      html += `<${tag.toLowerCase()}>${inner}</${tag.toLowerCase()}>`;
    } else if (tag === 'SPAN' && node.dataset.color) {
      html += wrap(inner, { color: node.dataset.color });
    } else if (tag === 'MARK' && node.dataset.highlight) {
      html += wrap(inner, { highlight: node.dataset.highlight });
    } else if (tag === 'A' && node.getAttribute('href')) {
      html += wrap(inner, { href: node.getAttribute('href') ?? '' });
    } else html += `<${tag}?>${inner}`;
  });
  return html;
}

/** The character offset of a DOM position inside `root`. */
export function offsetOf(root: Node, node: Node, offset: number): number {
  const range = document.createRange();
  range.setStart(root, 0);
  range.setEnd(node, offset);
  return range.toString().length;
}

/** The DOM position of a character offset: in the text node that holds it, at the end of a run. */
export function pointAt(root: Node, offset: number): { node: Node; offset: number } {
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
  let left = offset;
  let last: Text | null = null;
  for (let node = walker.nextNode(); node; node = walker.nextNode()) {
    const text = node as Text;
    if (left <= text.data.length) return { node: text, offset: left };
    left -= text.data.length;
    last = text;
  }
  return last
    ? { node: last, offset: last.data.length }
    : { node: root, offset: root.childNodes.length };
}

/** A DOM range over `span`. */
export function rangeOf(root: Node, span: Span): Range {
  const range = document.createRange();
  const start = pointAt(root, span.start);
  const end = pointAt(root, span.end);
  range.setStart(start.node, start.offset);
  range.setEnd(end.node, end.offset);
  return range;
}

/** The selection's span inside `root`, when the selection is there. */
export function selectedSpan(root: Node): Span | null {
  const selection = document.getSelection();
  if (!selection?.rangeCount) return null;
  const range = selection.getRangeAt(0);
  if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return null;
  const start = offsetOf(root, range.startContainer, range.startOffset);
  const end = offsetOf(root, range.endContainer, range.endOffset);
  return { start: Math.min(start, end), end: Math.max(start, end) };
}

/** Selects `span` inside `root`. */
export function select(root: Node, span: Span) {
  const selection = document.getSelection();
  if (!selection) return;
  selection.removeAllRanges();
  selection.addRange(rangeOf(root, span));
}
```

### event-setup/icons.tsx

```tsx
/*
 * The card's icons: outlined, drawn on an 18 × 18 grid with round caps, in the current colour.
 * They are decorative; the controls that hold them carry the labels.
 */

type IconProps = { className?: string };

const outline = {
  viewBox: '0 0 18 18',
  fill: 'none',
  stroke: 'currentColor',
  strokeWidth: 1.4,
  strokeLinecap: 'round' as const,
  strokeLinejoin: 'round' as const,
  'aria-hidden': true,
};

/** A note with a ribbon marker. */
export function TitleIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <rect x="3.7" y="2.7" width="10.6" height="12.6" rx="2" />
      <path d="M8.2 2.9v4.3l1.4-1 1.4 1V2.9" />
    </svg>
  );
}

/** A calendar page. */
export function DateIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <rect x="2.7" y="3.7" width="12.6" height="11.6" rx="2" />
      <path d="M2.9 7.2h12.2M6.2 2.3v2.6M11.8 2.3v2.6" />
      <path
        d="M6 10h.01M9 10h.01M12 10h.01M6 12.6h.01M9 12.6h.01"
        strokeWidth="1.6"
        strokeLinecap="round"
      />
    </svg>
  );
}

/** A clock. */
export function TimeIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <circle cx="9" cy="9" r="6.4" />
      <path d="M9 5.6V9l2.3 1.6" />
    </svg>
  );
}

/** A list of tasks. */
export function DescriptionIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <rect x="2.6" y="3" width="3.6" height="3.6" rx="0.9" />
      <rect x="2.6" y="11.4" width="3.6" height="3.6" rx="0.9" />
      <path d="M9 3.6h6.4M9 6.2h4M9 12h6.4M9 14.6h4" />
    </svg>
  );
}

export function PencilIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="M11.4 3.7a1.7 1.7 0 0 1 2.4 2.4l-7.6 7.6-3.2.8.8-3.2z" />
      <path d="m10.3 4.8 2.9 2.9" />
    </svg>
  );
}

export function CheckIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="m3.8 9.4 3.4 3.4 7-7.6" />
    </svg>
  );
}

export function CrossIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="m4.6 4.6 8.8 8.8m0-8.8-8.8 8.8" />
    </svg>
  );
}

export function ChevronIcon({ className, back }: IconProps & { back?: boolean }) {
  return (
    <svg className={className} {...outline}>
      <path d={back ? 'M10.8 5 6.8 9l4 4' : 'm7.2 5 4 4-4 4'} />
    </svg>
  );
}

/** Two links of a chain. */
export function LinkIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="m7.6 10.4 2.8-2.8" />
      <path d="M8.3 5.5 9.6 4.2a3 3 0 0 1 4.2 4.2l-1.3 1.3" />
      <path d="M9.7 12.5 8.4 13.8a3 3 0 0 1-4.2-4.2l1.3-1.3" />
    </svg>
  );
}

/** The card's close button: a white cross on a grey disc (a dark cross on a grey one at night). */
export function CloseIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 18 18" aria-hidden="true">
      <circle cx="9" cy="9" r="9" fill="currentColor" />
      <path
        d="m6.3 6.3 5.4 5.4m0-5.4-5.4 5.4"
        fill="none"
        style={{ stroke: 'var(--close-cross)' }}
        strokeWidth="1.6"
        strokeLinecap="round"
      />
    </svg>
  );
}
```

### event-setup/ghost-pointer.tsx

```tsx
'use client';

import type { Ref } from 'react';
import styles from './event-setup.module.css';

/*
 * The demo's ghost pointer, and the timeline that moves it. A demo is a script of awaited steps:
 * wait, glide the pointer onto a control, press it and call the component's own handler, as a
 * click would. The pointer takes the shape a real one would: an arrow, a hand over buttons, an
 * I-beam over text. 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.
 */

export type Shape = 'arrow' | 'hand' | 'text';

/** A point in the stage's own pixels. */
export type Point = { x: number; y: number };

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /**
   * Glides the pointer onto `target`: an element (its middle, or a point given as shares of its
   * size) or a point on the stage. `ms` sets the glide's length; by default it follows the
   * distance.
   */
  moveTo: (
    target: Element | Point | null | undefined,
    options?: { at?: Point; shape?: Shape; ms?: number },
  ) => Promise<void>;
  /** Presses and lets go; `action` runs as the pointer goes down. */
  press: (action: () => void) => Promise<void>;
  /** A point on the screen, in the stage's pixels. */
  fromScreen: (x: number, y: number) => Point;
};

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

/** Plays `script` with `pointer` over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: HTMLElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.78, y: stage.offsetHeight * 0.92 };

  /** 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 fromScreen: Demo['fromScreen'] = (x, y) => {
    const frame = stage.getBoundingClientRect();
    // Screen pixels to the stage's own, should the stage sit in a scaled container.
    const scale = frame.width / stage.offsetWidth || 1;
    return { x: (x - frame.left) / scale, y: (y - frame.top) / scale };
  };

  const locate = (target: Element, share: Point = { x: 0.5, y: 0.5 }) => {
    const box = target.getBoundingClientRect();
    return fromScreen(box.left + box.width * share.x, box.top + box.height * share.y);
  };

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

  const moveTo: Demo['moveTo'] = (target, options = {}) => {
    if (options.shape) pointer.dataset.shape = options.shape;
    if (!target) return step(Promise.resolve());
    const to = target instanceof Element ? locate(target, options.at) : target;
    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.14, y: (from.y + to.y) / 2 + dx * 0.14 };
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 12; i += 1) {
      const t = i / 12;
      const u = 1 - t;
      keyframes.push({
        transform: place({
          x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
          y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
        }),
      });
    }
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(keyframes, {
      duration: options.ms ?? Math.min(900, Math.max(340, Math.hypot(dx, dy) * 1.5)),
      easing: options.ms ? 'linear' : 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    return step(flight.finished);
  };

  const press: Demo['press'] = async (action) => {
    const shape = pointer.querySelector(`[data-shape-of="${pointer.dataset.shape}"]`);
    const tap = shape?.animate([{ scale: 1 }, { scale: 0.84, offset: 0.35 }, { scale: 1 }], {
      duration: 260,
      easing: 'ease-in-out',
    });
    await wait(90);
    action();
    if (tap) await step(tap.finished);
  };

  pointer.dataset.shape = 'arrow';
  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, moveTo, press, fromScreen }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

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

/** The I-beam's outline, drawn twice: its edge (white at night), then the beam. */
const BEAM =
  'M7 3h1.4c.7 0 1.2.3 1.6.8.4-.5.9-.8 1.6-.8H13M10 3.9v12.2M7 17h1.4c.7 0 1.2-.3 1.6-.8.4.5.9.8 1.6.8H13';

/** The pointer the demo moves around. It is decorative: the demo's effects show on screen. */
export function GhostPointer({ ref }: { ref: Ref<HTMLSpanElement> }) {
  return (
    <span ref={ref} className={styles.ghost} data-shape="arrow" aria-hidden="true">
      <svg data-shape-of="arrow" viewBox="0 0 20 20">
        <path d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z" />
      </svg>
      <svg data-shape-of="hand" viewBox="0 0 20 20">
        <path d="M6.1 2.7a1.5 1.5 0 0 1 3 0v4.7a1.25 1.25 0 0 1 2.5.2v.4a1.2 1.2 0 0 1 2.4.4v.6a1.1 1.1 0 0 1 2.2.3V13c0 2.6-1.6 4.6-4.4 4.6H9.6c-1.6 0-2.6-.7-3.5-1.9L3.3 12.1c-.6-.8-.4-1.8.4-2.2.7-.4 1.5-.1 2 .5l.4.6z" />
        <path d="M9.1 8v2.6M11.6 8.3v2.3M14 9.2v1.9" fill="none" />
      </svg>
      <svg data-shape-of="text" viewBox="0 0 20 20">
        <path data-edge="" d={BEAM} />
        <path d={BEAM} />
      </svg>
    </span>
  );
}
```

### event-setup/spring.ts

```tsx
/*
 * Springs as CSS easings. A spring pulls a value to its target and lets it settle: fast at first,
 * then slowing down, overshooting a little and coming back if it is bouncy. CSS `linear()` draws
 * any curve through a list of points, so the spring's motion is sampled once, and the same curve
 * serves CSS transitions and the Web Animations API.
 */

export type Spring = {
  /** The curve, as a CSS `linear()` easing. */
  easing: string;
  /** How long it takes to settle, in ms: the duration to play the curve over. */
  duration: number;
};

/** Points sampled along the curve: plenty for a smooth motion. */
const SAMPLES = 40;

/**
 * A spring that covers most of the distance in about `response` ms. With a `bounce` of 0 it
 * settles without overshooting; at 0.3 it overshoots by about 5% and comes back.
 */
export function spring(bounce: number, response: number): Spring {
  // The damping ratio (1: no overshoot) and the natural frequency, in radians per second.
  const damping = 1 - bounce;
  const omega = (2 * Math.PI * 1000) / response;
  const position = (t: number) => {
    if (damping >= 1) return 1 - Math.exp(-omega * t) * (1 + omega * t);
    const wobble = omega * Math.sqrt(1 - damping * damping);
    const decay = Math.exp(-damping * omega * t);
    return 1 - decay * (Math.cos(wobble * t) + ((damping * omega) / wobble) * Math.sin(wobble * t));
  };
  // Settled: from then on, within 0.1% of the target.
  let settled = 0;
  for (let t = 0; t < 3; t += 0.002) if (Math.abs(1 - position(t)) > 0.001) settled = t;
  const points = Array.from({ length: SAMPLES + 1 }, (_, i) =>
    i === SAMPLES ? 1 : Math.round(position((i / SAMPLES) * settled) * 1e4) / 1e4,
  );
  return { easing: `linear(${points.join(', ')})`, duration: Math.round(settled * 1000) };
}

/** The spring's easing for `element.animate()`, or an ease-out where `linear()` is unknown. */
export function easingOf({ easing }: Spring) {
  return CSS.supports('transition-timing-function', easing)
    ? easing
    : 'cubic-bezier(0.2, 0.8, 0.2, 1)';
}
```

### event-setup/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 };
}
```