# Call lobby

> A video call’s waiting room: check your mic and camera while you wait, and the host slides in.

The lobby of a video call. Your camera’s picture fills the window inside a thin golden edge, a card says the host is not there yet, and the bar below lets you mute, switch devices and go full screen. When the host arrives, the card greets them and their picture slides in.

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

Category: Media.

## Anatomy

1. **Status chip**: Waiting for host… with a turning spinner, or Connected with a green dot once the host is in.
2. **Camera preview**: Your own picture fills the window, edged in gold. Picking another camera reframes it; turning the camera off shows your initials.
3. **Waiting card**: Says the host is not there yet and asks to check the devices. It turns into “<name> joined”, then leaves after 3.6 s.
4. **Full screen**: The browser’s own full screen for the window, hidden where the browser has none.
5. **Chat picker**: Who can read the chat: private or everyone. Its icon and words swap with the pick.
6. **Microphone**: A toggle that says Mute or Unmute, and a chevron that opens the microphone and speaker menu.
7. **Call tools**: Call audio, share screen, invite, more and settings, each with a tooltip. More waits for the host.
8. **Chat**: Opens the call’s chat; the app decides how, through `onAction`.

## When to use

- The moment before a video call starts, while the person waits for a host to let the call begin.
- Checking the microphone, the speaker and the camera before anyone else can see or hear you.
- Calls with one host who opens them: a consultation, an interview, a lesson, office hours.
- Showing an arrival as it happens: the card says who joined and their picture slides in, with nothing to reload.

## When not to use

- The call itself, with several people on screen. Use a call grid with a speaker view.
- Calls anyone can open at once, with no host to wait for. Go straight into the call.
- Audio-only calls: the picture is most of the lobby. Use a compact device check instead.
- Setting devices outside a call, for good. That belongs in the app’s settings page.

## Installation

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

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

Copy these media files to the same paths under your public folder:

- https://brunodelorence.com/experiments/call-lobby/maya.mp4
- https://brunodelorence.com/experiments/call-lobby/maya.webp
- https://brunodelorence.com/experiments/call-lobby/maya.vtt
- https://brunodelorence.com/experiments/call-lobby/jonah.mp4
- https://brunodelorence.com/experiments/call-lobby/jonah.webp
- https://brunodelorence.com/experiments/call-lobby/jonah.vtt

## Usage

```tsx
import { CallLobby, type LobbyAction } from './call-lobby';

export function WaitingRoom({
  hostIsHere,
  onAction,
}: {
  hostIsHere: boolean;
  onAction: (action: LobbyAction) => void;
}) {
  return (
    <CallLobby
      name="Maya Lind"
      hostName="Jonah Reyes"
      hostJoined={hostIsHere}
      onAction={onAction}
      autoplay={false}
    />
  );
}
```

## States

- **Waiting for host**: The start: microphone muted, camera on, the card asking to check the devices.
- **Microphone on**: The icon swaps to an open mic and turns from grey to full ink; the tooltip now says Mute.
- **Camera off**: The picture fades to your initials and name on a soft radial backdrop.
- **Device menu**: Microphone and speaker in two groups, the current one ticked. Arrow keys move, Escape closes.
- **Another camera**: The desk camera picked: the whole picture reframes, closer and lower.
- **Host joined**: The card greets the host, the chip says Connected, their picture slides in and More turns on.
- **In the call**: The greeting has left; your picture and the host’s stay, with every control as it was.

## How it works

- Every state is in the page from the start: both icons of each toggle, the avatar for a camera that is off, the host’s picture, both faces of the card and every menu. A change flips data attributes, and the stylesheet swaps, fades and slides with transforms and opacity.
- The window is drawn in one unit, a 685th of the stage’s width, so it keeps its proportions at any width. A narrow stage switches to a larger fixed unit so its words stay readable, and full screen sizes the unit from the screen.
- The camera feeds are local MP4 clips of two fictional adults, generated with Seedance 2.5 through Higgsfield. They are demonstration footage, not live camera or microphone capture. Picking another camera reframes the whole video. Supply selfFeed and hostFeed to use your own clips.
- The videos start silently and only play while the lobby is visible in an active tab. Turning your camera off pauses your feed; the host’s feed plays only after they join. The call-audio button enables one voice at a time with English captions, independently of the microphone toggle.
- The device menus follow the menu pattern (arrow keys, Home, End, Escape) and close when you click elsewhere. Full screen uses the browser’s own, and its button hides where there is none.
- When hostJoined turns true, the chip turns to Connected, the card becomes “Jonah joined” and leaves a few seconds later, and the host’s picture springs in at the top right. Parts that change during the demo paint on small layers of their own, and the spinners stop off screen.
- Every interface colour is a light-dark() token on the root, so the lobby follows the page’s color-scheme, or the colorScheme prop: a white window on a pale slate by day, with grey pills on a white bar and white menus, card and tooltips; the dark window at night. The camera pictures, and what sits on them, are the same on both; with the camera off, the full-screen button takes the window’s ink.

## API reference

- **name** (`string`, default `'Maya Lind'`): Your name, on the avatar when your camera is off.
- **hostName** (`string`, default `'Jonah Reyes'`): The host’s name, on their picture and in the card when they join.
- **hostJoined** (`boolean`, default `false`): Whether the host is in the call; when it turns true, the lobby greets them.
- **defaultMicOn** (`boolean`, default `false`): Whether the microphone starts on.
- **defaultCameraOn** (`boolean`, default `true`): Whether the camera starts on.
- **defaultCamera** (`string`, default `'built-in'`): The camera picked at the start: 'built-in', 'desk' or 'phone'. Given, it turns the demo off.
- **defaultMenu** (`'chat' | 'mic' | 'camera'`): A device menu open at the start, without taking the focus. Turns the demo off.
- **defaultCard** (`'joined' | 'gone'`): With hostJoined true at the start, the card held: 'joined' keeps the greeting up, 'gone' starts with the call under way. Turns the demo off.
- **brand** (`string`, default `'Openroom'`): The product’s name, at the top left.
- **onAction** (`(action: LobbyAction) => void`): Called when share screen, invite, more, settings or chat is pressed.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer tries the controls and the host arrives.
- **selfFeed** (`LobbyFeed`, default `Maya’s local clip`): Your camera clip: { src, poster, captions? }. Captions are English WebVTT.
- **hostFeed** (`LobbyFeed`, default `Jonah’s local clip`): The host’s clip, poster and optional English captions; plays after arrival.
- **still** (`boolean`, default `false`): Shows the cameras’ poster pictures instead of their clips and stops the spinners. Turns the demo off.
- **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.

## Accessibility

- Mic and camera are toggle buttons (aria-pressed) with tooltips. Each chevron is a menu button (aria-haspopup, aria-expanded) whose choices are radio menu items, reached with the arrow keys; Escape closes the menu and hands focus back to its button.
- Your own changes are announced in a polite live region (“Microphone on.”, “Camera: Desk camera.”), and so is a real host arrival; the demo stays silent.
- The preview footage is decorative and never starts with sound. The call-audio toggle is labelled, keyboard accessible and exposes aria-pressed. Enabling sound also shows English captions. The host’s picture is an image named after them, hidden while they are away. Every control shows a visible focus ring.
- Under reduced motion the spinners stand still, every change is instant and the automatic demo never plays. The camera feeds show still posters unless the reader explicitly enables call audio. Videos pause offscreen and in a hidden tab.

## Source

### call-lobby/call-lobby.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
  type ReactNode,
} from 'react';
import { DemoPointer, runDemo, type Demo } from './demo-pointer';
import { CameraFeed, type LobbyFeed } from './camera-feed';
import {
  AddPersonIcon,
  CameraIcon,
  CameraOffIcon,
  ChatIcon,
  ChevronIcon,
  CollapseIcon,
  ExpandIcon,
  GlobeIcon,
  LockIcon,
  MicIcon,
  MicOffIcon,
  PlusIcon,
  SettingsIcon,
  ShareIcon,
  SpeakerIcon,
  SpeakerOffIcon,
  SpinnerDots,
} from './icons';
import { Menu, type Choice } from './menu';
import { useAutoplay } from './use-autoplay';
import styles from './call-lobby.module.css';

/*
 * Call lobby: the waiting room of a video call. Your camera's picture fills the window, a card
 * says the host is not there yet, and the controls below let you check your microphone and camera
 * while you wait. When the host arrives, the card says so and their picture slides in.
 *
 * - Every state is already in the page: both icons of each toggle, the avatar for a camera that is
 *   off, the host's picture, both faces of the card, every menu. A change only flips data
 *   attributes, and the stylesheet fades, slides and swaps what shows, with transforms and
 *   opacity. Nothing is added to or taken out of the page, so a change never lays out more than
 *   the window.
 * - Fictional participants filmed with Seedance stand in for camera streams. Picking another
 *   camera reframes the whole video; clips pause offscreen and when their camera is hidden.
 * - The device menus (menu.tsx) follow the menu pattern, keys included, and close when you click
 *   elsewhere. Full screen uses the browser's own, where there is one.
 * - The host arrives when `hostJoined` turns true: the card turns into "<name> joined", then
 *   leaves. Your own changes are announced to screen readers, and so is the host's arrival.
 * - While on screen and untouched, a demo pointer unmutes the microphone, picks another camera,
 *   and the host arrives; then it all goes back, and again.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the lobby follows the
 *   page's color-scheme, or the one the colorScheme prop sets: a white window on a pale slate, or
 *   a dark one on a deep slate. The camera pictures, and what sits on them, are the same on both.
 */

export type MenuName = 'chat' | 'mic' | 'camera';

/** What the other controls ask of the app. */
export type LobbyAction = 'share' | 'invite' | 'more' | 'settings' | 'chat';

const MICS: Choice[] = [
  { id: 'built-in', label: 'Built-in microphone' },
  { id: 'usb', label: 'Studio USB microphone' },
  { id: 'headset', label: 'Wireless headset' },
];
const SPEAKERS: Choice[] = [
  { id: 'built-in', label: 'Built-in speakers' },
  { id: 'headset', label: 'Wireless headset' },
];
const CAMERAS: Choice[] = [
  { id: 'built-in', label: 'Built-in camera' },
  { id: 'desk', label: 'Desk camera' },
  { id: 'phone', label: 'Phone camera' },
];
/** How each camera frames you: the picture's scale and offset, in % of its box. */
const FRAMING: Record<string, { scale: number; x: number; y: number }> = {
  'built-in': { scale: 1, x: 0, y: 0 },
  desk: { scale: 1.16, x: -4, y: 5 },
  phone: { scale: 1.08, x: 3, y: 2 },
};
const CHATS: Choice[] = [
  { id: 'private', label: 'Private chat', icon: <LockIcon className={styles.menuIcon} /> },
  { id: 'everyone', label: 'Everyone', icon: <GlobeIcon className={styles.menuIcon} /> },
];

/** How long the "joined" card stays before it leaves, in ms. */
const JOINED_CARD = 3600;

const SELF_FEED: LobbyFeed = {
  src: '/experiments/call-lobby/maya.mp4',
  poster: '/experiments/call-lobby/maya.webp',
  captions: '/experiments/call-lobby/maya.vtt',
};
const HOST_FEED: LobbyFeed = {
  src: '/experiments/call-lobby/jonah.mp4',
  poster: '/experiments/call-lobby/jonah.webp',
  captions: '/experiments/call-lobby/jonah.vtt',
};

export type { LobbyFeed } from './camera-feed';

const labelOf = (choices: Choice[], id: string) =>
  choices.find((choice) => choice.id === id)?.label ?? id;

const initialsOf = (name: string) =>
  name
    .split(/\s+/)
    .filter(Boolean)
    .slice(0, 2)
    .map((part) => part[0]?.toUpperCase())
    .join('');

/** What the demo does to the lobby, one step at a time. */
type DemoAct =
  | { type: 'mic' }
  | { type: 'menu'; menu: MenuName }
  | { type: 'camera'; id: string }
  | { type: 'host'; joined: boolean };

export function CallLobby({
  name = 'Maya Lind',
  hostName = 'Jonah Reyes',
  hostJoined = false,
  defaultMicOn = false,
  defaultCameraOn = true,
  brand = 'Openroom',
  onAction,
  autoplay = true,
  selfFeed = SELF_FEED,
  hostFeed = HOST_FEED,
  colorScheme,
  defaultCamera,
  defaultMenu,
  defaultCard,
  still = false,
  className = '',
}: {
  /** Your name: on the avatar when your camera is off. */
  name?: string;
  /** The host's name: on their picture, and in the card when they join. */
  hostName?: string;
  /** Whether the host is in the call. When it turns true, the lobby greets them. */
  hostJoined?: boolean;
  /** Whether the microphone starts on. */
  defaultMicOn?: boolean;
  /** Whether the camera starts on. */
  defaultCameraOn?: boolean;
  /** The product's name, at the top left. */
  brand?: string;
  /** Called when share screen, invite, more, settings or chat is pressed. */
  onAction?: (action: LobbyAction) => void;
  /** While on screen and untouched, a demo pointer tries the controls and the host arrives. */
  autoplay?: boolean;
  /** A camera clip, its still poster and optional English captions. */
  selfFeed?: LobbyFeed;
  /** The host's camera clip; loaded when the host arrives. */
  hostFeed?: LobbyFeed;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** The camera picked at the start: 'built-in', 'desk' or 'phone'. Turns the demo off. */
  defaultCamera?: string;
  /** A device menu open at the start, without taking focus. Turns the demo off. */
  defaultMenu?: MenuName;
  /**
   * With hostJoined true at the start, the card's face, held: 'joined' keeps the greeting up,
   * 'gone' starts with the call under way. Turns the demo off.
   */
  defaultCard?: 'joined' | 'gone';
  /** Shows the cameras' still pictures and stops the spinners. Turns the demo off. */
  still?: boolean;
  className?: string;
}) {
  const { ref: root, playing, take, visible, reduced } = useAutoplay<HTMLDivElement>(7000);
  const [micOn, setMicOn] = useState(defaultMicOn);
  const [cameraOn, setCameraOn] = useState(defaultCameraOn);
  const [audioOn, setAudioOn] = useState(false);
  const [mic, setMic] = useState('built-in');
  const [speaker, setSpeaker] = useState('built-in');
  const [camera, setCamera] = useState(defaultCamera ?? 'built-in');
  const [chat, setChat] = useState('private');
  const [menu, setMenu] = useState<MenuName | null>(defaultMenu ?? null);
  /** The menu was opened by the reader, so focus goes into it. */
  const [menuFocus, setMenuFocus] = useState(false);
  const [demoHost, setDemoHost] = useState(false);
  /** The "joined" card has had its moment. */
  const [cardGone, setCardGone] = useState(hostJoined && defaultCard === 'gone');
  /** The card's face is held by defaultCard until the host leaves. */
  const held = useRef(hostJoined && defaultCard !== undefined);
  const [fullscreen, setFullscreen] = useState(false);
  const [canFullscreen, setCanFullscreen] = useState(true);
  const [onScreen, setOnScreen] = useState(false);
  const [message, setMessage] = useState('');
  const uid = useId();
  const windowRef = useRef<HTMLDivElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const groups = useRef<Record<MenuName, HTMLDivElement | null>>({
    chat: null,
    mic: null,
    camera: null,
  });
  const micButton = useRef<HTMLButtonElement>(null);
  /** The open menu was opened by the demo, which closes it when it stops. */
  const menuByDemo = useRef(false);

  const joined = hostJoined || demoHost;
  const locked =
    still || defaultCamera !== undefined || defaultMenu !== undefined || defaultCard !== undefined;
  const demo = autoplay && playing && !locked;
  // Taking over the demo does not interrupt the camera feeds. Reduced motion shows their
  // posters, unless the reader explicitly asks to listen to the call.
  const playVideo = visible && !still && (!reduced || audioOn);

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

  // The host's arrival: the card turns, stays a moment, and leaves. Their arrival from the app
  // is announced; the demo's is not.
  const greeted = useRef(hostJoined);
  useEffect(() => {
    if (!joined) {
      held.current = false;
      setCardGone(false);
      return;
    }
    if (held.current) return;
    const timer = window.setTimeout(() => setCardGone(true), JOINED_CARD);
    return () => window.clearTimeout(timer);
  }, [joined]);
  useEffect(() => {
    if (hostJoined && !greeted.current) announce(`${hostName} joined the call.`);
    greeted.current = hostJoined;
  }, [hostJoined, hostName]);

  // The spinners turn only while the lobby is on screen.
  useEffect(() => {
    const element = root.current;
    if (!element) return;
    const observer = new IntersectionObserver(([entry]) => setOnScreen(entry.isIntersecting));
    observer.observe(element);
    return () => observer.disconnect();
  }, [root]);

  // Full screen: the browser's own, kept in step with it; the button hides where there is none.
  useEffect(() => {
    setCanFullscreen(Boolean(document.fullscreenEnabled));
    const sync = () => setFullscreen(document.fullscreenElement === windowRef.current);
    document.addEventListener('fullscreenchange', sync);
    return () => document.removeEventListener('fullscreenchange', sync);
  }, []);

  // A click anywhere but the open menu or its button closes it.
  useEffect(() => {
    if (!menu) return;
    const close = (event: PointerEvent) => {
      if (!groups.current[menu]?.contains(event.target as Node)) setMenu(null);
    };
    document.addEventListener('pointerdown', close);
    return () => document.removeEventListener('pointerdown', close);
  }, [menu]);

  /** The reader opens or closes a menu; focus goes into it as it opens. */
  const toggleMenu = (name: MenuName) => {
    menuByDemo.current = false;
    setMenuFocus(true);
    setMenu((now) => (now === name ? null : name));
  };

  const closeMenu = (refocus: boolean) => {
    const was = menu;
    setMenu(null);
    if (refocus && was) {
      groups.current[was]?.querySelector<HTMLButtonElement>('[aria-haspopup="menu"]')?.focus();
    }
  };

  const toggleMic = (byReader: boolean) => {
    setMicOn(!micOn);
    if (byReader) announce(micOn ? 'Microphone muted.' : 'Microphone on.');
  };

  const toggleCamera = () => {
    setCameraOn(!cameraOn);
    announce(cameraOn ? 'Camera off.' : 'Camera on.');
  };

  const pick = (kind: 'mic' | 'speaker' | 'camera' | 'chat', id: string) => {
    if (kind === 'mic') setMic(id);
    else if (kind === 'speaker') setSpeaker(id);
    else if (kind === 'camera') setCamera(id);
    else setChat(id);
    const choices = { mic: MICS, speaker: SPEAKERS, camera: CAMERAS, chat: CHATS }[kind];
    const what = { mic: 'Microphone', speaker: 'Speaker', camera: 'Camera', chat: 'Chat' }[kind];
    announce(`${what}: ${labelOf(choices, id)}.`);
    closeMenu(true);
  };

  const toggleFullscreen = () => {
    const element = windowRef.current;
    if (!element) return;
    if (document.fullscreenElement) void document.exitFullscreen();
    else void element.requestFullscreen().catch(() => setCanFullscreen(false));
  };

  /** A menu button opens its menu with Enter, Space or the arrow keys. */
  const onMenuKey = (event: KeyboardEvent<HTMLButtonElement>, name: MenuName) => {
    if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
      event.preventDefault();
      menuByDemo.current = false;
      setMenuFocus(true);
      setMenu(name);
    }
  };

  // The demo: unmute, pick the desk camera, and the host arrives; then everything goes back.
  const demoAct = useEffectEvent((act: DemoAct) => {
    if (act.type === 'mic') toggleMic(false);
    else if (act.type === 'menu') {
      menuByDemo.current = true;
      setMenuFocus(false);
      setMenu(act.menu);
    } else if (act.type === 'camera') {
      setCamera(act.id);
      setMenu(null);
    } else setDemoHost(act.joined);
  });
  const readState = useEffectEvent(() => ({ micOn, camera }));
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!demo || !stage || !arrow) return;
    const choice = (id: string) =>
      groups.current.camera?.querySelector(`[data-choice="${id}"]`) ?? null;
    const chevron = () => groups.current.camera?.querySelector('[aria-haspopup="menu"]') ?? null;
    const pickCamera = async ({ wait, moveTo, press }: Demo, id: string) => {
      await moveTo(chevron());
      await press(() => demoAct({ type: 'menu', menu: 'camera' }));
      await wait(700);
      await moveTo(choice(id), { x: 0.3, y: 0.5 });
      await press(() => demoAct({ type: 'camera', id }));
    };
    const stop = runDemo(stage, arrow, async (steps) => {
      const { wait, moveTo, moveToStage, press } = steps;
      for (;;) {
        await wait(1100);
        // A muted microphone is turned on; one left on stays on.
        if (!readState().micOn) {
          await moveTo(micButton.current);
          await press(() => demoAct({ type: 'mic' }));
          await wait(800);
        }
        await pickCamera(steps, readState().camera === 'desk' ? 'phone' : 'desk');
        await wait(500);
        await moveToStage({ x: 0.66, y: 0.56 });
        await wait(1300);
        demoAct({ type: 'host', joined: true });
        await wait(JOINED_CARD + 1400);
        // Back to the start: muted, the built-in camera, the host away.
        await moveTo(micButton.current);
        await press(() => demoAct({ type: 'mic' }));
        await wait(500);
        await pickCamera(steps, 'built-in');
        await wait(400);
        await moveToStage({ x: 0.82, y: 0.62 });
        demoAct({ type: 'host', joined: false });
        await wait(1400);
      }
    });
    return () => {
      stop();
      if (menuByDemo.current) setMenu(null);
      menuByDemo.current = false;
      setDemoHost(false);
    };
  }, [demo, root]);

  const framing = FRAMING[camera] ?? FRAMING['built-in'];
  const firstName = hostName.split(/\s+/)[0] ?? hostName;
  const cardPhase = !joined ? 'waiting' : cardGone ? 'gone' : 'joined';

  /** A button with a tooltip above it, and its label for screen readers. */
  const tool = (
    label: string,
    icon: ReactNode,
    action: LobbyAction,
    extra?: { disabled?: boolean; className?: string },
  ) => (
    <button
      type="button"
      className={`${styles.tool} ${extra?.className ?? ''}`}
      aria-label={label}
      aria-disabled={extra?.disabled || undefined}
      onClick={() => {
        if (!extra?.disabled) onAction?.(action);
      }}
    >
      {icon}
      <span className={styles.tip} aria-hidden="true">
        {label}
      </span>
    </button>
  );

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      data-idle={!onScreen || still || undefined}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onKeyDown={(event) => {
        if (event.key === 'Escape' && menu) closeMenu(true);
      }}
    >
      <div ref={windowRef} className={styles.window} data-fullscreen={fullscreen || undefined}>
        <header className={styles.header}>
          <span className={styles.brand} data-part="brand">
            {brand}
          </span>
          <span className={styles.chip} data-part="status" data-joined={joined || undefined}>
            <span className={styles.chipWaiting}>
              <span className={styles.spin} aria-hidden="true">
                <SpinnerDots className={styles.spinner} />
              </span>
              Waiting for host…
            </span>
            <span className={styles.chipLive}>
              <span className={styles.liveDot} aria-hidden="true" />
              Connected
            </span>
          </span>
        </header>

        <div
          className={styles.preview}
          data-part="preview"
          data-camera={cameraOn ? 'on' : 'off'}
          role="group"
          aria-label="Your camera"
        >
          <div className={styles.picture}>
            <div
              className={styles.framing}
              style={
                {
                  '--scale': framing.scale,
                  '--x': `${framing.x}%`,
                  '--y': `${framing.y}%`,
                } as CSSProperties
              }
            >
              <CameraFeed
                feed={selfFeed}
                active={playVideo && cameraOn}
                audible={audioOn && !joined}
              />
            </div>
          </div>
          <div className={styles.avatar} aria-hidden="true">
            <span className={styles.avatarDisc} data-part="avatar">
              {initialsOf(name)}
            </span>
            <span className={styles.avatarName}>{name}</span>
          </div>

          <div
            className={styles.host}
            data-part="host"
            data-in={joined || undefined}
            role="img"
            aria-label={`${hostName}'s camera`}
            aria-hidden={!joined || undefined}
          >
            <CameraFeed feed={hostFeed} active={playVideo && joined} audible={audioOn && joined} />
            <span className={styles.hostName}>{hostName}</span>
          </div>

          <div
            className={styles.card}
            data-part="card"
            data-phase={cardPhase}
            aria-hidden={cardPhase === 'gone'}
          >
            <div className={styles.cardFace} data-face="waiting">
              <p className={styles.cardTitle}>
                <span className={styles.spin} aria-hidden="true">
                  <SpinnerDots className={styles.cardSpinner} />
                </span>
                Waiting for host…
              </p>
              <p className={styles.cardText}>
                We’re waiting for the other person to arrive, whilst you wait make sure your mic and
                camera settings are correct.
              </p>
            </div>
            <div className={styles.cardFace} data-face="joined">
              <p className={styles.cardTitle}>
                <span className={styles.joinedDot} aria-hidden="true" />
                {firstName} joined
              </p>
              <p className={styles.cardText}>
                The call has started. Say hello when you’re ready, your mic and camera stay just as
                they are.
              </p>
            </div>
          </div>

          <button
            type="button"
            className={styles.fullscreen}
            data-part="fullscreen"
            aria-label={fullscreen ? 'Exit full screen' : 'Full screen'}
            aria-pressed={fullscreen}
            data-on={fullscreen || undefined}
            hidden={!canFullscreen}
            onClick={toggleFullscreen}
          >
            <span className={styles.whenOff}>
              <ExpandIcon />
            </span>
            <span className={styles.whenOn}>
              <CollapseIcon />
            </span>
          </button>
        </div>

        <div className={styles.bar} data-part="bar">
          <div
            ref={(element) => {
              groups.current.chat = element;
            }}
            className={styles.chatPicker}
          >
            <button
              type="button"
              className={styles.chatButton}
              data-part="chat-picker"
              aria-haspopup="menu"
              aria-expanded={menu === 'chat'}
              aria-controls={`${uid}chat`}
              onClick={() => toggleMenu('chat')}
              onKeyDown={(event) => onMenuKey(event, 'chat')}
            >
              <span className={styles.chatIcons} aria-hidden="true">
                <span className={styles.chatIcon} data-show={chat === 'private' || undefined}>
                  <LockIcon />
                </span>
                <span className={styles.chatIcon} data-show={chat === 'everyone' || undefined}>
                  <GlobeIcon />
                </span>
              </span>
              <span className={styles.chatLabel}>{labelOf(CHATS, chat)}</span>
              <ChevronIcon className={styles.chatChevron} />
            </button>
            <Menu
              id={`${uid}chat`}
              label="Chat"
              open={menu === 'chat'}
              focusOnOpen={menuFocus}
              align="start"
              sections={[
                {
                  label: 'Who can read the chat',
                  choices: CHATS,
                  value: chat,
                  onPick: (id) => pick('chat', id),
                },
              ]}
              onClose={closeMenu}
            />
          </div>

          <div className={styles.controls}>
            <div
              ref={(element) => {
                groups.current.mic = element;
              }}
              className={styles.pill}
              data-part="mic"
            >
              <button
                ref={micButton}
                type="button"
                className={styles.tool}
                data-on={micOn || undefined}
                aria-label="Microphone"
                aria-pressed={micOn}
                onClick={() => toggleMic(true)}
              >
                <span className={styles.whenOn}>
                  <MicIcon />
                </span>
                <span className={styles.whenOff}>
                  <MicOffIcon />
                </span>
                <span className={styles.tip} aria-hidden="true">
                  {micOn ? 'Mute' : 'Unmute'}
                </span>
              </button>
              <span className={styles.divider} aria-hidden="true" />
              <button
                type="button"
                className={styles.chevron}
                aria-label="Microphone and speaker"
                aria-haspopup="menu"
                aria-expanded={menu === 'mic'}
                aria-controls={`${uid}mic`}
                onClick={() => toggleMenu('mic')}
                onKeyDown={(event) => onMenuKey(event, 'mic')}
              >
                <ChevronIcon />
              </button>
              <Menu
                id={`${uid}mic`}
                label="Microphone and speaker"
                open={menu === 'mic'}
                focusOnOpen={menuFocus}
                sections={[
                  {
                    label: 'Microphone',
                    choices: MICS,
                    value: mic,
                    onPick: (id) => pick('mic', id),
                  },
                  {
                    label: 'Speaker',
                    choices: SPEAKERS,
                    value: speaker,
                    onPick: (id) => pick('speaker', id),
                  },
                ]}
                onClose={closeMenu}
              />
            </div>

            <div
              ref={(element) => {
                groups.current.camera = element;
              }}
              className={styles.pill}
              data-part="camera"
            >
              <button
                type="button"
                className={styles.tool}
                data-on={cameraOn || undefined}
                aria-label="Camera"
                aria-pressed={cameraOn}
                onClick={toggleCamera}
              >
                <span className={styles.whenOn}>
                  <CameraIcon />
                </span>
                <span className={styles.whenOff}>
                  <CameraOffIcon />
                </span>
                <span className={styles.tip} aria-hidden="true">
                  {cameraOn ? 'Turn camera off' : 'Turn camera on'}
                </span>
              </button>
              <span className={styles.divider} aria-hidden="true" />
              <button
                type="button"
                className={styles.chevron}
                aria-label="Camera"
                aria-haspopup="menu"
                aria-expanded={menu === 'camera'}
                aria-controls={`${uid}camera`}
                onClick={() => toggleMenu('camera')}
                onKeyDown={(event) => onMenuKey(event, 'camera')}
              >
                <ChevronIcon />
              </button>
              <Menu
                id={`${uid}camera`}
                label="Camera"
                open={menu === 'camera'}
                focusOnOpen={menuFocus}
                sections={[
                  {
                    label: 'Camera',
                    choices: CAMERAS,
                    value: camera,
                    onPick: (id) => pick('camera', id),
                  },
                ]}
                onClose={closeMenu}
              />
            </div>

            <div className={styles.pill} data-part="tools">
              <button
                type="button"
                className={styles.tool}
                data-on={audioOn || undefined}
                aria-label="Call audio"
                aria-pressed={audioOn}
                onClick={() => {
                  setAudioOn((value) => !value);
                  announce(audioOn ? 'Call audio off.' : 'Call audio on.');
                }}
              >
                <span className={styles.whenOn}>
                  <SpeakerIcon />
                </span>
                <span className={styles.whenOff}>
                  <SpeakerOffIcon />
                </span>
                <span className={styles.tip} aria-hidden="true">
                  {audioOn ? 'Mute call audio' : 'Listen to call'}
                </span>
              </button>
              {tool('Share screen', <ShareIcon />, 'share')}
              {tool('Invite someone', <AddPersonIcon />, 'invite', {
                className: styles.invite,
              })}
              {tool('More', <PlusIcon />, 'more', {
                disabled: !joined,
                className: styles.more,
              })}
              {tool('Settings', <SettingsIcon />, 'settings')}
            </div>
          </div>

          <button
            type="button"
            className={styles.chat}
            data-part="chat"
            onClick={() => onAction?.('chat')}
          >
            <ChatIcon className={styles.chatGlyph} />
            <span className={styles.chatWord}>Chat</span>
          </button>
        </div>
      </div>

      <DemoPointer ref={pointer} />
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### call-lobby/call-lobby.module.css

```css
/* Call lobby (call-lobby.tsx): a slate stage with the call's window in it. Header, camera
   preview, control bar. Every state is drawn already; the component flips data attributes and
   the rules below show one face or the other, with opacity, visibility and transforms.

   Colours are the tokens below, light-dark(light, dark): the lobby takes the color-scheme it
   inherits from the page (light unless the page says otherwise), or the one its colorScheme prop
   sets (data-color-scheme). By day the window is white on a pale slate, at night dark on a deep
   one. The camera pictures are content, the same on both, and so is what sits on them: the
   full-screen button over your picture, and the frame round the host's.

   Memory. A phone keeps the page's pixels in tiles the width of the screen, and a tile painted
   again takes a second buffer. So every part that changes while the demo plays paints on a small
   layer of its own (will-change): the status chip, the card, the pills of controls and their
   menus, the host's picture, your picture (which moves as a whole when another camera is
   picked), the line for screen readers. Nothing else is painted again. None of those layers sits
   in a box that clips with rounded corners but your picture's own, and the stage is one layout
   boundary, sized from its width, so nothing inside lays out the page. The spinners stop when the
   lobby is off screen. */

.root {
  /* The stage, the window, its edge and its shadow. */
  --slate: #dfe1e6;
  --night: #fff;
  --edge: rgb(0 0 0 / 0.08);
  --shadow: rgb(0 0 0 / 0.14);
  /* The words: the brand, titles and controls; the card's text and your name; idle controls. */
  --ink: #1d1f27;
  --soft: #5d606b;
  --dim: #80838d;
  /* The status chip in the header, and the dot that says the call is live (and its ring). */
  --chip: #eef0f3;
  --chip-ink: #5d606b;
  --live: #1a8f4e;
  --live-ring: rgb(26 143 78 / 0.16);
  --joined-ring: rgb(26 143 78 / 0.18);
  /* The spinners' dots: the lit one, and the others (dimmer round the ring). */
  --spinner-lead: #a87412;
  --spinner-dot: #1d1f27;
  /* The frame round your picture, and the backdrop behind your avatar while the camera is off
     (its centre and its edge), the avatar's ring, and the full-screen button's hover there. */
  --gold: #e2a531;
  --backdrop: #f4f5f8;
  --backdrop-edge: #e3e5ea;
  --avatar-ring: rgb(0 0 0 / 0.06);
  --backdrop-hover: rgb(0 0 0 / 0.06);
  /* The card over your picture, and the label on the host's picture. */
  --card: rgb(255 255 255 / 0.96);
  --label: rgb(255 255 255 / 0.88);
  /* The control bar and its top edge, the pills, a control under the pointer, the controls'
     icons and chevrons, and the rule between them. By day the bar is the window's white and the
     pills are grey on it. */
  --bar: #fff;
  --bar-edge: rgb(255 255 255 / 0);
  --pill: #eceef2;
  --pill-hover: #dfe2e8;
  --tool: #1d1f27;
  --chevron: #5d606b;
  --divider: #d3d6dd;
  /* A tooltip: its fill and its shadow. */
  --tip: #fff;
  --tip-shadow: rgb(0 0 0 / 0.2);
  /* The chat picker at the left (its fill, under the pointer, its words and its icons) and Chat
     at the right, under the pointer. */
  --picker: #eceef2;
  --picker-hover: #dfe2e8;
  --picker-ink: #2b2d36;
  --picker-icon: #6b6e78;
  --picker-chevron: #6b6e78;
  --chat-hover: #e9ebef;
  /* The menus: their fill, edge and shadow, headings, rules, items, an item under the pointer,
     their icons, and the tick by the chosen one. */
  --menu: #fff;
  --menu-edge: rgb(0 0 0 / 0.08);
  --menu-shadow: rgb(0 0 0 / 0.18);
  --menu-heading: #6b6e78;
  --menu-rule: rgb(0 0 0 / 0.07);
  --menu-ink: #1d1f27;
  --menu-hover: #f0f1f4;
  --menu-icon: #6b6e78;
  --check: #a87412;
  --focus: #2f6fdb;
  /* The same on both: what sits on the camera pictures (the full-screen button and its hover, the
     frame round the host's picture and its shadow), and the initials on the avatar. */
  --on-picture: #f4f4f6;
  --on-picture-hover: rgb(4 5 14 / 0.5);
  --host-edge: rgb(255 255 255 / 0.14);
  --host-shadow: rgb(0 0 0 / 0.5);
  --avatar-ink: #f4f4f6;
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  --spring: cubic-bezier(0.3, 1.32, 0.42, 1);
  /* One unit of the drawing: the stage is 685 units wide, and everything in it is drawn in units,
     so the window keeps its proportions at any width (a narrow stage sets a unit of its own). */
  --u: calc(100cqi / 685);
  position: relative;
  container-type: inline-size;
  contain: size layout;
  width: 100%;
  aspect-ratio: 685 / 439;
  min-height: 360px;
  border-radius: 18px;
  background: var(--slate);
  box-shadow: inset 0 0 0 1px rgb(0 0 0 / 0.06);
  color: var(--ink);
  font-family:
    Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
  font-weight: 400;
  line-height: 1.4;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --slate: light-dark(#dfe1e6, #333740);
    --night: light-dark(#fff, #04050f);
    --edge: light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.5));
    --shadow: light-dark(rgb(0 0 0 / 0.14), rgb(0 0 0 / 0.35));
    --ink: light-dark(#1d1f27, #f4f4f6);
    --soft: light-dark(#5d606b, #a4a6b0);
    --dim: light-dark(#80838d, #6e7079);
    --chip: light-dark(#eef0f3, #1a1b27);
    --chip-ink: light-dark(#5d606b, #9a9ca7);
    --live: light-dark(#1a8f4e, #3fcf7f);
    --live-ring: light-dark(rgb(26 143 78 / 0.16), rgb(63 207 127 / 0.18));
    --joined-ring: light-dark(rgb(26 143 78 / 0.18), rgb(63 207 127 / 0.2));
    --spinner-lead: light-dark(#a87412, #f4c25a);
    --spinner-dot: light-dark(#1d1f27, #c9cad3);
    --gold: light-dark(#e2a531, #ebb03d);
    --backdrop: light-dark(#f4f5f8, #1a1b27);
    --backdrop-edge: light-dark(#e3e5ea, #08090f);
    --avatar-ring: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.06));
    --backdrop-hover: light-dark(rgb(0 0 0 / 0.06), rgb(4 5 14 / 0.5));
    --card: light-dark(rgb(255 255 255 / 0.96), rgb(2 3 14 / 0.96));
    --label: light-dark(rgb(255 255 255 / 0.88), rgb(2 3 14 / 0.8));
    --bar: light-dark(#fff, #0d0d18);
    --bar-edge: light-dark(rgb(255 255 255 / 0), rgb(255 255 255 / 0.035));
    --pill: light-dark(#eceef2, #1e212b);
    --pill-hover: light-dark(#dfe2e8, #2a2d38);
    --tool: light-dark(#1d1f27, #f6f7f9);
    --chevron: light-dark(#5d606b, #c4c6cc);
    --divider: light-dark(#d3d6dd, #2e2f39);
    --tip: light-dark(#fff, #23252f);
    --tip-shadow: light-dark(rgb(0 0 0 / 0.2), rgb(0 0 0 / 0.4));
    --picker: light-dark(#eceef2, #1b1c26);
    --picker-hover: light-dark(#dfe2e8, #23252f);
    --picker-ink: light-dark(#2b2d36, #e3e4e9);
    --picker-icon: light-dark(#6b6e78, #5f6069);
    --picker-chevron: light-dark(#6b6e78, #5c5d67);
    --chat-hover: light-dark(#e9ebef, #1a1b25);
    --menu: light-dark(#fff, #15161f);
    --menu-edge: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.08));
    --menu-shadow: light-dark(rgb(0 0 0 / 0.18), rgb(0 0 0 / 0.55));
    --menu-heading: light-dark(#6b6e78, #7d7f89);
    --menu-rule: light-dark(rgb(0 0 0 / 0.07), rgb(255 255 255 / 0.07));
    --menu-ink: light-dark(#1d1f27, #e6e7ec);
    --menu-hover: light-dark(#f0f1f4, #252733);
    --menu-icon: light-dark(#6b6e78, #8d8f99);
    --check: light-dark(#a87412, #ebb03d);
    --focus: light-dark(#2f6fdb, #6ea8ff);
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}
.root *,
.root *::before,
.root *::after {
  box-sizing: border-box;
}
/* Buttons start from the stage's type; :where() keeps this below every class. */
:where(.root) button {
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  cursor: pointer;
}
:where(.root) p {
  margin: 0;
}
:where(.root) svg {
  display: block;
}
.root :focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* The window. */
.window {
  position: absolute;
  inset: calc(24.5 * var(--u));
  display: flex;
  flex-direction: column;
  padding: 0 calc(1.5 * var(--u)) calc(2 * var(--u));
  border-radius: calc(5.5 * var(--u));
  background: var(--night);
  box-shadow:
    0 0 0 1px var(--edge),
    0 calc(4 * var(--u)) calc(9 * var(--u)) var(--shadow);
  font-size: calc(5.7 * var(--u));
}
/* Full screen, the window is drawn to the screen's width instead. */
.window:fullscreen {
  --u: calc(100vw / 636);
  inset: 0;
  border-radius: 0;
}

.header {
  display: flex;
  flex: none;
  align-items: center;
  justify-content: space-between;
  height: calc(23.5 * var(--u));
  padding: 0 calc(8.5 * var(--u)) 0 calc(9 * var(--u));
}
.brand {
  font-size: calc(9.1 * var(--u));
  font-weight: 700;
  letter-spacing: -0.03em;
}
/* The status: waiting, then connected, in one cell. */
.chip {
  display: grid;
  align-items: center;
  height: calc(13 * var(--u));
  padding: 0 calc(5 * var(--u)) 0 calc(4.5 * var(--u));
  border-radius: calc(2.5 * var(--u));
  background: var(--chip);
  color: var(--chip-ink);
  font-size: calc(5.3 * var(--u));
  font-weight: 500;
  will-change: opacity;
}
.chipWaiting,
.chipLive {
  display: flex;
  grid-area: 1 / 1;
  align-items: center;
  gap: calc(4.2 * var(--u));
  white-space: nowrap;
  transition:
    opacity 0.3s ease,
    visibility 0.3s;
}
.chipLive,
.chip[data-joined] .chipWaiting {
  visibility: hidden;
  opacity: 0;
}
.chip[data-joined] .chipLive {
  visibility: visible;
  opacity: 1;
}
.liveDot {
  width: calc(3.6 * var(--u));
  height: calc(3.6 * var(--u));
  margin: 0 calc(1.7 * var(--u));
  border-radius: 50%;
  background: var(--live);
  box-shadow: 0 0 0 calc(1.5 * var(--u)) var(--live-ring);
}

/* The spinners: a ring of dots that turns in steps, only while the lobby is on screen. */
.spin {
  display: block;
  flex: none;
  animation: turn 0.96s steps(8) infinite;
}
.root[data-idle] .spin,
.chip[data-joined] .spin,
.card[data-phase='joined'] .spin,
.card[data-phase='gone'] .spin {
  animation-play-state: paused;
}
@keyframes turn {
  to {
    transform: rotate(360deg);
  }
}
.spinner {
  width: calc(7 * var(--u));
  height: calc(7 * var(--u));
}
.spin circle {
  fill: var(--spinner-dot);
}
.spin circle:first-child {
  fill: var(--spinner-lead);
}

/* The preview: your picture, or your avatar; the host's picture; the card. */
.preview {
  position: relative;
  flex: 1;
  min-height: 0;
  border: max(1px, calc(0.7 * var(--u))) solid var(--gold);
  border-radius: calc(5 * var(--u));
  background: radial-gradient(ellipse at 50% 45%, var(--backdrop), var(--backdrop-edge) 70%);
}
/* Your picture, clipped to the preview's corners; reframed by another camera. */
.picture {
  position: absolute;
  inset: 0;
  overflow: hidden;
  overflow: clip;
  border-radius: calc(4.3 * var(--u));
  transition:
    opacity 0.35s ease,
    visibility 0.35s;
}
.framing {
  position: absolute;
  inset: 0;
  transform: translate(var(--x), var(--y)) scale(var(--scale));
  transition: transform 0.7s var(--ease-out);
  will-change: transform;
}
.scene {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
  background: #1b2029;
}
.scene::cue {
  font-size: max(12px, calc(8 * var(--u)));
  color: #fff;
  background: rgb(0 0 0 / 0.75);
}
.preview[data-camera='off'] .picture {
  visibility: hidden;
  opacity: 0;
}
.avatar {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: calc(5 * var(--u));
  visibility: hidden;
  opacity: 0;
  transform: scale(0.94);
  transition:
    opacity 0.35s ease,
    transform 0.45s var(--ease-out),
    visibility 0.35s;
}
.preview[data-camera='off'] .avatar {
  visibility: visible;
  opacity: 1;
  transform: scale(1);
}
.avatarDisc {
  display: grid;
  place-items: center;
  width: calc(42 * var(--u));
  height: calc(42 * var(--u));
  border-radius: 50%;
  background: linear-gradient(140deg, #c98a5a, #7b4a8c);
  box-shadow: 0 0 0 calc(2 * var(--u)) var(--avatar-ring);
  color: var(--avatar-ink);
  font-size: calc(14 * var(--u));
  font-weight: 600;
  letter-spacing: 0.02em;
}
.avatarName {
  color: var(--soft);
  font-size: calc(5.7 * var(--u));
  font-weight: 500;
}

/* The host's picture: in at the top right. */
.host {
  position: absolute;
  z-index: 2;
  top: calc(6 * var(--u));
  right: calc(6 * var(--u));
  width: 27%;
  aspect-ratio: 16 / 10;
  overflow: hidden;
  overflow: clip;
  border-radius: calc(3.4 * var(--u));
  background: #1b2029;
  box-shadow:
    0 0 0 1px var(--host-edge),
    0 calc(5 * var(--u)) calc(14 * var(--u)) var(--host-shadow);
  visibility: hidden;
  opacity: 0;
  transform: translateX(calc(14 * var(--u))) scale(0.94);
  transition:
    transform 0.62s var(--spring),
    opacity 0.3s ease,
    visibility 0.62s;
  will-change: opacity;
}
.host[data-in] {
  visibility: visible;
  opacity: 1;
  transform: none;
}
.hostName {
  position: absolute;
  bottom: calc(3 * var(--u));
  left: calc(3 * var(--u));
  padding: calc(1.4 * var(--u)) calc(3.4 * var(--u));
  border-radius: calc(2.4 * var(--u));
  background: var(--label);
  font-size: calc(4.8 * var(--u));
  font-weight: 500;
}

/* The card, bottom left: waiting, then joined, then gone. */
.card {
  position: absolute;
  z-index: 2;
  bottom: calc(6 * var(--u));
  left: calc(6 * var(--u));
  display: grid;
  width: min(calc(131 * var(--u)), calc(100% - 12 * var(--u)));
  padding: calc(5.9 * var(--u)) calc(6.6 * var(--u)) calc(6.1 * var(--u));
  border-radius: calc(3.4 * var(--u));
  background: var(--card);
  transition:
    opacity 0.4s ease,
    transform 0.5s var(--ease-out),
    visibility 0.5s;
  will-change: opacity;
}
.card[data-phase='gone'] {
  visibility: hidden;
  opacity: 0;
  transform: translateY(calc(5 * var(--u)));
}
.cardFace {
  grid-area: 1 / 1;
  transition:
    opacity 0.3s ease,
    visibility 0.3s;
}
.cardFace[data-face='joined'],
.card:not([data-phase='waiting']) .cardFace[data-face='waiting'] {
  visibility: hidden;
  opacity: 0;
}
.card:not([data-phase='waiting']) .cardFace[data-face='joined'] {
  visibility: visible;
  opacity: 1;
}
.cardTitle {
  display: flex;
  align-items: center;
  gap: calc(4.4 * var(--u));
  font-size: calc(5.1 * var(--u));
  font-weight: 600;
  line-height: calc(7.7 * var(--u));
}
.cardSpinner {
  width: calc(7.6 * var(--u));
  height: calc(7.6 * var(--u));
}
.joinedDot {
  flex: none;
  width: calc(4.4 * var(--u));
  height: calc(4.4 * var(--u));
  margin: 0 calc(1.6 * var(--u));
  border-radius: 50%;
  background: var(--live);
  box-shadow: 0 0 0 calc(1.5 * var(--u)) var(--joined-ring);
}
.cardText {
  margin-top: calc(4.4 * var(--u));
  color: var(--soft);
  font-size: calc(5.16 * var(--u));
  line-height: calc(7.7 * var(--u));
}

.fullscreen {
  position: absolute;
  z-index: 4;
  right: calc(1.6 * var(--u));
  bottom: calc(2.2 * var(--u));
  display: grid;
  place-items: center;
  width: calc(12.6 * var(--u));
  height: calc(12.6 * var(--u));
  border-radius: calc(2.4 * var(--u));
  color: var(--on-picture);
}
.fullscreen svg {
  width: 100%;
  height: 100%;
  stroke-width: 2;
}
.fullscreen:hover {
  background: var(--on-picture-hover);
}
/* With the camera off, it sits on the backdrop instead, in the window's colours. */
.preview[data-camera='off'] .fullscreen {
  color: var(--ink);
}
.preview[data-camera='off'] .fullscreen:hover {
  background: var(--backdrop-hover);
}
.fullscreen[hidden] {
  display: none;
}

/* Two icons per toggle, one shown: on, or off. */
.whenOn,
.whenOff {
  display: grid;
  grid-area: 1 / 1;
  place-items: center;
}
.whenOn,
[data-on] > .whenOff {
  visibility: hidden;
}
[data-on] > .whenOn {
  visibility: visible;
}

/* The control bar. */
.bar {
  position: relative;
  z-index: 5;
  display: grid;
  flex: none;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  height: calc(34.5 * var(--u));
  margin-top: calc(1.5 * var(--u));
  padding: 0 calc(10 * var(--u));
  border-radius: calc(4 * var(--u));
  background: var(--bar);
  box-shadow: inset 0 1px 0 var(--bar-edge);
}
.controls {
  display: flex;
  gap: calc(4 * var(--u));
}
.pill {
  position: relative;
  display: flex;
  align-items: center;
  height: calc(17.7 * var(--u));
  border-radius: calc(8.85 * var(--u));
  background: var(--pill);
  will-change: opacity;
}

.tool,
.chevron {
  position: relative;
  display: grid;
  place-items: center;
  height: 100%;
  border-radius: calc(8.85 * var(--u));
  color: var(--tool);
  transition: background-color 0.15s ease;
}
.tool {
  width: calc(21 * var(--u));
}
.tool svg {
  width: calc(11.3 * var(--u));
  height: calc(11.3 * var(--u));
}
.tool[aria-pressed='false'] {
  color: var(--dim);
}
.tool[aria-disabled='true'] {
  color: var(--dim);
  cursor: default;
}
.chevron {
  width: calc(17 * var(--u));
  color: var(--chevron);
}
.chevron svg {
  width: calc(7.6 * var(--u));
  height: calc(7.6 * var(--u));
  stroke-width: 2.4;
}
.tool:hover:not([aria-disabled='true']),
.chevron:hover,
.chevron[aria-expanded='true'] {
  background: var(--pill-hover);
}
.divider {
  flex: none;
  width: max(1px, calc(0.35 * var(--u)));
  height: calc(7 * var(--u));
  background: var(--divider);
}
/* A tooltip over a tool, after a moment's hover. */
.tip {
  position: absolute;
  bottom: calc(100% + 5 * var(--u));
  left: 50%;
  padding: calc(2 * var(--u)) calc(3.6 * var(--u));
  border-radius: calc(2.6 * var(--u));
  background: var(--tip);
  box-shadow: 0 calc(2 * var(--u)) calc(7 * var(--u)) var(--tip-shadow);
  color: var(--ink);
  font-size: calc(5 * var(--u));
  font-weight: 500;
  white-space: nowrap;
  pointer-events: none;
  visibility: hidden;
  opacity: 0;
  transform: translate(-50%, calc(1.6 * var(--u)));
  transition:
    opacity 0.15s ease,
    transform 0.15s ease,
    visibility 0.15s;
}
.tool:hover > .tip,
.tool:focus-visible > .tip {
  visibility: visible;
  opacity: 1;
  transform: translate(-50%, 0);
  transition-delay: 0.45s;
}

/* Private chat, at the left. */
.chatPicker {
  position: relative;
  justify-self: start;
  margin-left: calc(1.5 * var(--u));
}
.chatButton {
  display: flex;
  align-items: center;
  height: calc(12.9 * var(--u));
  padding: 0 calc(1.9 * var(--u)) 0 calc(2.7 * var(--u));
  border-radius: calc(2.2 * var(--u));
  background: var(--picker);
  color: var(--picker-ink);
  font-size: calc(5.2 * var(--u));
  font-weight: 500;
  white-space: nowrap;
  transition: background-color 0.15s ease;
  will-change: opacity;
}
.chatButton:hover,
.chatButton[aria-expanded='true'] {
  background: var(--picker-hover);
}
.chatIcons {
  display: grid;
  margin-right: calc(3 * var(--u));
  color: var(--picker-icon);
}
.chatIcon {
  grid-area: 1 / 1;
  visibility: hidden;
}
.chatIcon[data-show] {
  visibility: visible;
}
.chatIcon svg {
  width: calc(8.2 * var(--u));
  height: calc(8.2 * var(--u));
}
.chatChevron {
  width: calc(8 * var(--u));
  height: calc(8 * var(--u));
  margin-left: calc(5.6 * var(--u));
  color: var(--picker-chevron);
  stroke-width: 1.8;
}

/* Chat, at the right. */
.chat {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-self: end;
  margin-right: calc(-1.5 * var(--u));
  padding: calc(1.2 * var(--u)) calc(2 * var(--u));
  border-radius: calc(2.4 * var(--u));
}
.chat:hover {
  background: var(--chat-hover);
}
.chatGlyph {
  width: calc(14.8 * var(--u));
  height: calc(14.8 * var(--u));
  margin: calc(-2.4 * var(--u)) 0 calc(-0.6 * var(--u));
}
.chatWord {
  font-size: calc(5.5 * var(--u));
  font-weight: 600;
  line-height: 1.2;
}

/* The menus, over the bar. */
.menu {
  position: absolute;
  z-index: 6;
  bottom: calc(100% + 5 * var(--u));
  left: 50%;
  width: calc(112 * var(--u));
  padding: calc(2.5 * var(--u));
  border-radius: calc(5 * var(--u));
  background: var(--menu);
  box-shadow:
    0 0 0 1px var(--menu-edge),
    0 calc(8 * var(--u)) calc(18 * var(--u)) var(--menu-shadow);
  visibility: hidden;
  opacity: 0;
  transform: translate(-50%, calc(3 * var(--u))) scale(0.98);
  transform-origin: 50% 100%;
  /* Hidden once faded out; shown at once, so that focus can go in as it opens. */
  transition:
    opacity 0.16s ease,
    transform 0.2s var(--ease-out),
    visibility 0s 0.2s;
  will-change: opacity;
}
.menu[data-align='start'] {
  left: 0;
  transform: translate(0, calc(3 * var(--u))) scale(0.98);
  transform-origin: 0 100%;
}
.menu[data-open] {
  visibility: visible;
  opacity: 1;
  transform: translate(-50%, 0) scale(1);
  transition-delay: 0s;
}
.menu[data-align='start'][data-open] {
  transform: translate(0, 0) scale(1);
}
.menuHeading {
  padding: calc(3 * var(--u)) calc(4 * var(--u)) calc(2 * var(--u));
  color: var(--menu-heading);
  font-size: calc(4.6 * var(--u));
  font-weight: 600;
  letter-spacing: 0.05em;
  text-transform: uppercase;
}
.menuRule {
  height: 1px;
  margin: calc(2.5 * var(--u)) calc(2 * var(--u));
  background: var(--menu-rule);
}
.menuItem {
  display: flex;
  align-items: center;
  gap: calc(4 * var(--u));
  width: 100%;
  height: calc(15 * var(--u));
  padding: 0 calc(4 * var(--u));
  border-radius: calc(3 * var(--u));
  color: var(--menu-ink);
  font-size: calc(5.7 * var(--u));
  text-align: left;
}
.menuItem:hover,
.menuItem:focus-visible,
.menuItem[data-pressed] {
  background: var(--menu-hover);
}
.menuItem:focus-visible {
  outline-offset: -2px;
}
.menuIcon {
  width: calc(7 * var(--u));
  height: calc(7 * var(--u));
  color: var(--menu-icon);
}
.menuLabel {
  flex: 1;
}
.menuCheck {
  width: calc(7 * var(--u));
  height: calc(7 * var(--u));
  color: var(--check);
  stroke-width: 2.2;
  visibility: hidden;
}
.menuItem[aria-checked='true'] > .menuCheck {
  visibility: visible;
}

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

/* Narrow, the window is drawn larger than to scale, so its words stay readable; tighter margins,
   the card across the picture, fewer words in the bar. */
@container (max-width: 560px) {
  .window {
    --u: 1.9px;
    inset: 12px 10px;
  }
  .header {
    height: calc(18 * var(--u));
    padding: 0 calc(5 * var(--u)) 0 calc(6 * var(--u));
  }
  .brand {
    font-size: calc(7.6 * var(--u));
  }
  .card {
    right: calc(18 * var(--u));
    width: auto;
  }
  .host {
    width: 38%;
  }
  .bar {
    grid-template-columns: auto 1fr auto;
    height: calc(28 * var(--u));
    padding: 0 calc(4 * var(--u));
  }
  .controls {
    justify-self: center;
    gap: calc(2.5 * var(--u));
  }
  .chatLabel,
  .chatWord,
  .more {
    display: none;
  }
  .chatButton {
    padding: 0 calc(2 * var(--u)) 0 calc(2.7 * var(--u));
  }
  .chatChevron {
    margin-left: calc(1 * var(--u));
  }
  .chatIcons {
    margin-right: 0;
  }
  .tool {
    width: calc(19 * var(--u));
  }
  .chevron {
    width: calc(14 * var(--u));
  }
}
@container (max-width: 380px) {
  .bar {
    padding: 0 calc(3 * var(--u));
  }
  .invite,
  .chatPicker {
    display: none;
  }
  .tool {
    width: calc(17 * var(--u));
  }
  .chevron {
    width: calc(12.5 * var(--u));
  }
}

@media (prefers-reduced-motion: reduce) {
  .spin {
    animation: none;
  }
  .framing,
  .picture,
  .avatar,
  .host,
  .card,
  .cardFace,
  .chipWaiting,
  .chipLive,
  .menu,
  .tip {
    transition: none;
  }
}
```

### call-lobby/camera-feed.tsx

```tsx
'use client';

import { useEffect, useRef } from 'react';
import styles from './call-lobby.module.css';

export type LobbyFeed = { src: string; poster: string; captions?: string };

/** The local clips stand in for camera streams; playback follows the lobby's visibility. */
export function CameraFeed({
  feed,
  active,
  audible,
}: {
  feed: LobbyFeed;
  active: boolean;
  audible: boolean;
}) {
  const video = useRef<HTMLVideoElement>(null);
  const wasAudible = useRef(false);

  useEffect(() => {
    const element = video.current;
    if (!element) return;
    let cancelled = false;
    element.muted = !audible;
    // Let the reader hear the whole greeting rather than the tail end of a silent loop.
    if (audible && !wasAudible.current) element.currentTime = 0;
    wasAudible.current = audible;
    const captions = element.textTracks[0];
    if (captions) captions.mode = audible ? 'showing' : 'disabled';
    if (active) {
      // A browser may require another user gesture before enabling sound. Keep the visual
      // preview playing silently when that happens, without an unhandled rejection.
      void element.play().catch(() => {
        if (cancelled) return;
        element.muted = true;
        void element.play().catch(() => {});
      });
    } else {
      element.pause();
    }
    return () => {
      cancelled = true;
      element.pause();
    };
  }, [active, audible, feed.src]);

  return (
    <video
      ref={video}
      className={styles.scene}
      src={feed.src}
      poster={feed.poster}
      preload="none"
      loop
      muted={!audible}
      playsInline
      disablePictureInPicture
      aria-hidden="true"
      tabIndex={-1}
    >
      {feed.captions && <track src={feed.captions} kind="captions" srcLang="en" label="English" />}
      Camera preview unavailable.
    </video>
  );
}
```

### call-lobby/menu.tsx

```tsx
'use client';

import { useEffect, useRef, type KeyboardEvent, type ReactNode } from 'react';
import { CheckIcon } from './icons';
import styles from './call-lobby.module.css';

/*
 * A menu of choices that opens above its button: the microphone, the speaker, the camera, the
 * chat. It is always in the page, hidden until open, so that opening it adds nothing to the page
 * and lays out nothing new. It follows the menu pattern: arrow keys, Home and End move between the
 * choices, Enter or Space picks one, Escape closes and hands focus back to its button.
 */

export type Choice = { id: string; label: string; icon?: ReactNode };

export type Section = {
  /** The heading of a group of choices. */
  label: string;
  choices: Choice[];
  value: string;
  onPick: (id: string) => void;
};

export function Menu({
  id,
  label,
  open,
  focusOnOpen,
  sections,
  align = 'center',
  onClose,
}: {
  id: string;
  /** The menu's accessible name. */
  label: string;
  open: boolean;
  /** Whether opening it moves focus in: yes for the reader's opening, no for the demo's. */
  focusOnOpen: boolean;
  sections: Section[];
  /** Which edge of its button it lines up with. */
  align?: 'start' | 'center';
  /** Closes it; `refocus` hands focus back to its button. */
  onClose: (refocus: boolean) => void;
}) {
  const list = useRef<HTMLDivElement>(null);

  // Opened by the reader: focus goes to the picked choice.
  useEffect(() => {
    if (!open || !focusOnOpen) return;
    const items = list.current?.querySelectorAll<HTMLElement>('[role="menuitemradio"]');
    const picked = Array.from(items ?? []).find(
      (item) => item.getAttribute('aria-checked') === 'true',
    );
    (picked ?? items?.[0])?.focus();
  }, [open, focusOnOpen]);

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const items = Array.from(
      list.current?.querySelectorAll<HTMLElement>('[role="menuitemradio"]') ?? [],
    );
    const at = items.indexOf(document.activeElement as HTMLElement);
    const go = (index: number) => items[(index + items.length) % items.length]?.focus();
    if (event.key === 'ArrowDown') go(at + 1);
    else if (event.key === 'ArrowUp') go(at < 0 ? items.length - 1 : at - 1);
    else if (event.key === 'Home') go(0);
    else if (event.key === 'End') go(items.length - 1);
    else if (event.key === 'Escape') onClose(true);
    else if (event.key === 'Tab') return onClose(false);
    else return;
    event.preventDefault();
    event.stopPropagation();
  };

  return (
    <div
      ref={list}
      id={id}
      className={styles.menu}
      data-part="menu"
      role="menu"
      aria-label={label}
      data-open={open || undefined}
      data-align={align}
      inert={!open}
      onKeyDown={onKeyDown}
    >
      {sections.map((section, index) => (
        <div key={section.label} role="group" aria-label={section.label}>
          {index > 0 && <div className={styles.menuRule} role="separator" />}
          <div className={styles.menuHeading} aria-hidden="true">
            {section.label}
          </div>
          {section.choices.map((choice) => (
            <button
              key={choice.id}
              type="button"
              role="menuitemradio"
              aria-checked={section.value === choice.id}
              className={styles.menuItem}
              data-choice={choice.id}
              tabIndex={-1}
              onClick={() => section.onPick(choice.id)}
            >
              {choice.icon}
              <span className={styles.menuLabel}>{choice.label}</span>
              <CheckIcon className={styles.menuCheck} />
            </button>
          ))}
        </div>
      ))}
    </div>
  );
}
```

### call-lobby/icons.tsx

```tsx
/*
 * The lobby's icons: outlines on a 24-unit grid, drawn in the current colour. Each one is a still
 * drawing; a control that changes state shows another icon beside it rather than restyling one.
 */

import type { ReactNode, SVGProps } from 'react';

type IconProps = SVGProps<SVGSVGElement>;

function Icon({ children, ...props }: IconProps & { children: ReactNode }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.7"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      focusable="false"
      {...props}
    >
      {children}
    </svg>
  );
}

export const MicIcon = (props: IconProps) => (
  <Icon {...props}>
    <rect x="9" y="3" width="6" height="11" rx="3" />
    <path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21" />
  </Icon>
);

export const MicOffIcon = (props: IconProps) => (
  <Icon {...props}>
    <path d="M15 9.4V6a3 3 0 0 0-5.7-1.3M9 9v2a3 3 0 0 0 4.9 2.3" />
    <path d="M18.5 11a6.5 6.5 0 0 1-.9 3.3M5.5 11a6.5 6.5 0 0 0 10.4 5.2M12 17.5V21M4 3l16 18" />
  </Icon>
);

export const CameraIcon = (props: IconProps) => (
  <Icon {...props}>
    <rect x="3" y="6" width="12.5" height="12" rx="2" />
    <path d="m15.5 10.5 5.5-3.5v10l-5.5-3.5" />
  </Icon>
);

export const CameraOffIcon = (props: IconProps) => (
  <Icon {...props}>
    <path d="M9 6h4.5a2 2 0 0 1 2 2v2.5l5.5-3.5v10l-2.6-1.7M15.5 15.5V16a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2M3 3l18 18" />
  </Icon>
);

export const ChevronIcon = (props: IconProps) => (
  <Icon {...props}>
    <path d="m7.5 10 4.5 4.5 4.5-4.5" />
  </Icon>
);

export const SpeakerIcon = (props: IconProps) => (
  <Icon {...props}>
    <path d="M11 4 6 8H3v8h3l5 4zM15 8a6 6 0 0 1 0 8M18 5a10 10 0 0 1 0 14" />
  </Icon>
);

export const SpeakerOffIcon = (props: IconProps) => (
  <Icon {...props}>
    <path d="M11 4 6 8H3v8h3l5 4zM16 9l5 6M21 9l-5 6" />
  </Icon>
);

export const ShareIcon = (props: IconProps) => (
  <Icon {...props}>
    <rect x="3" y="4.5" width="18" height="14" rx="1.6" />
    <path d="M12 15V8.5M9 11.3 12 8.3l3 3" />
  </Icon>
);

export const AddPersonIcon = (props: IconProps) => (
  <Icon {...props}>
    <circle cx="10" cy="8" r="3.8" />
    <path d="M3.5 20a6.5 6.5 0 0 1 11.6-4M18.5 12.5v6M15.5 15.5h6" />
  </Icon>
);

export const PlusIcon = (props: IconProps) => (
  <Icon {...props}>
    <path d="M12 6.5v11M6.5 12h11" />
  </Icon>
);

export const SettingsIcon = (props: IconProps) => (
  <Icon {...props}>
    <path d="M10.3 3.2a1.8 1.8 0 0 1 3.4 0l.3.9a1.8 1.8 0 0 0 2.4 1l.9-.4a1.8 1.8 0 0 1 2.4 2.4l-.4.9a1.8 1.8 0 0 0 1 2.4l.9.3a1.8 1.8 0 0 1 0 3.4l-.9.3a1.8 1.8 0 0 0-1 2.4l.4.9a1.8 1.8 0 0 1-2.4 2.4l-.9-.4a1.8 1.8 0 0 0-2.4 1l-.3.9a1.8 1.8 0 0 1-3.4 0l-.3-.9a1.8 1.8 0 0 0-2.4-1l-.9.4a1.8 1.8 0 0 1-2.4-2.4l.4-.9a1.8 1.8 0 0 0-1-2.4l-.9-.3a1.8 1.8 0 0 1 0-3.4l.9-.3a1.8 1.8 0 0 0 1-2.4l-.4-.9A1.8 1.8 0 0 1 6.7 4.7l.9.4a1.8 1.8 0 0 0 2.4-1z" />
    <circle cx="12" cy="12" r="3" />
  </Icon>
);

export const ChatIcon = (props: IconProps) => (
  <Icon {...props}>
    <path d="M12 4.2c4.7 0 8.5 3 8.5 6.8s-3.8 6.8-8.5 6.8c-1.1 0-2.1-.2-3-.5l-4.2 1.9 1.1-3.3C4.4 14.7 3.5 12.9 3.5 11c0-3.8 3.8-6.8 8.5-6.8z" />
    <path d="M8.6 11h6.8" />
  </Icon>
);

export const LockIcon = (props: IconProps) => (
  <Icon {...props}>
    <rect x="5" y="10.5" width="14" height="10" rx="2.2" />
    <path d="M8.2 10.5V7.8a3.8 3.8 0 0 1 7.6 0v2.7" />
  </Icon>
);

export const GlobeIcon = (props: IconProps) => (
  <Icon {...props}>
    <circle cx="12" cy="12" r="8.5" />
    <path d="M3.5 12h17M12 3.5c2.4 2.4 3.5 5.2 3.5 8.5s-1.1 6.1-3.5 8.5c-2.4-2.4-3.5-5.2-3.5-8.5S9.6 5.9 12 3.5z" />
  </Icon>
);

export const ExpandIcon = (props: IconProps) => (
  <Icon {...props}>
    <path d="M4 9V5.5A1.5 1.5 0 0 1 5.5 4H9M15 4h3.5A1.5 1.5 0 0 1 20 5.5V9M20 15v3.5a1.5 1.5 0 0 1-1.5 1.5H15M9 20H5.5A1.5 1.5 0 0 1 4 18.5V15" />
  </Icon>
);

export const CollapseIcon = (props: IconProps) => (
  <Icon {...props}>
    <path d="M9 4v3.5A1.5 1.5 0 0 1 7.5 9H4M20 9h-3.5A1.5 1.5 0 0 1 15 7.5V4M15 20v-3.5a1.5 1.5 0 0 1 1.5-1.5H20M4 15h3.5A1.5 1.5 0 0 1 9 16.5V20" />
  </Icon>
);

export const CheckIcon = (props: IconProps) => (
  <Icon {...props}>
    <path d="m5 12.5 4.5 4.5L19 7.5" />
  </Icon>
);

/**
 * The dotted spinner: eight dots, one lit, the rest dimmer around the ring. Their colours are the
 * stylesheet's (.spin circle), so they follow the colour scheme.
 */
export const SpinnerDots = (props: IconProps) => (
  <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" {...props}>
    {Array.from({ length: 8 }, (_, i) => {
      const angle = (i / 8) * Math.PI * 2 - Math.PI / 2;
      return (
        <circle
          key={i}
          cx={(12 + Math.cos(angle) * 8.5).toFixed(2)}
          cy={(12 + Math.sin(angle) * 8.5).toFixed(2)}
          r="1.9"
          opacity={i === 0 ? 1 : 0.15 + (i / 8) * 0.55}
        />
      );
    })}
  </svg>
);
```

### call-lobby/demo-pointer.tsx

```tsx
'use client';

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

/*
 * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, glide
 * the pointer onto a control, press it and call the component's own handler, as a click would.
 * Stopping the demo (the reader takes over, or it scrolls away) ends the step in progress at once:
 * its promise rejects, which ends the script right there, and the pointer fades out.
 *
 * The pointer is an HTML box around a still drawing: it glides by an animation the compositor
 * plays, and the press scales a box that keeps a transform at rest, so nothing it does needs a
 * layout.
 */

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

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */
  moveTo: (target: Element | null | undefined, at?: Point) => Promise<void>;
  /** Glides the pointer to a point of the stage, as shares of its size. */
  moveToStage: (at: Point) => Promise<void>;
  /** Presses and lets go; `action` runs as the pointer goes down. */
  press: (action: () => void) => Promise<void>;
};

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

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

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

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

  /** Settles like `promise`, or rejects as soon as the demo stops. */
  const step = (promise: Promise<unknown>) =>
    new Promise<void>((resolve, reject) => {
      if (!alive) return reject(new Stopped());
      const cancel = () => reject(new Stopped());
      pending.add(cancel);
      promise.then(
        () => {
          pending.delete(cancel);
          if (alive) resolve();
        },
        () => {
          pending.delete(cancel);
          reject(new Stopped());
        },
      );
    });

  const wait = (ms: number) =>
    step(
      new Promise<void>((resolve) => {
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          resolve();
        }, ms);
        timers.add(timer);
      }),
    );

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

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

  const moveToStage: Demo['moveToStage'] = (share) =>
    glide({ x: stage.offsetWidth * share.x, y: stage.offsetHeight * share.y });

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

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

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

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

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

const DRAWING: CSSProperties = { display: 'block', width: 22, height: 22, overflow: 'visible' };

/** The arrow the demo moves around. It is decorative: the demo's effects show on the controls. */
export function DemoPointer({ ref }: { ref: Ref<HTMLSpanElement> }) {
  return (
    <span ref={ref} style={POINTER} aria-hidden="true">
      <span style={PRESS}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          <path
            d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
            fill="#fff"
            stroke="#1c1c1e"
            strokeWidth="1.2"
            strokeLinejoin="round"
          />
        </svg>
      </span>
    </span>
  );
}
```

### call-lobby/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, visible, reduced, playing: visible && !reduced && !taken };
}
```