# System menu

> A game pause screen’s system page: a list of session verbs with a bright, breathing selection frame, confirm dialogs, and a handheld diagram with cyan callouts.

The page a player opens to stop the world and manage the session. A short list of verbs in two groups (Save, Load, then Options, Ability Controls, Extras, Return to Title) where exactly one item wears the cream frame with corner ticks and a soft glow, so the selection reads from across a room. Verbs that change the session ask first, in a small dialog with its own framed Yes and No, then run a bar and a check. Beside the list, a generic handheld console carries callouts for what each button does, paged like a game’s controls pages: the plates wipe out of their cyan bars, the leaders draw to their buttons and a ring pops round each target. It all sits on its own dark plate, so it reads on a light or a dark page.

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

Category: Games · Menus.

## Anatomy

1. **Plate**: The screen’s own dark plate, a gradient from #1D1E1A to #10110F: everything inside is a multiple of one design unit, 1 px at 900 px wide and less below, so it scales as one drawing.
2. **System list**: A `nav` landmark of real buttons in groups, with a 38-unit gap where the group changes. One tab stop: focus follows the selection.
3. **Item**: A near-black 175 × 38 plate with a faint edge and italic cream type; hovering it with a mouse selects it.
4. **Selected frame**: The cursor: a cream outline with a tick in each corner and a glow inside and out. It blooms in on selection and breathes while it rests.
5. **Page heading**: The controls page’s title between two flourish buttons that page it (so do Q and E or the left and right arrows), over a dot per page.
6. **Console diagram**: The handheld drawn in SVG, with a cyan ring on each target a callout points at; aria-hidden, since the callouts say the same in text.
7. **Callout**: A plate fading out of a cyan bar, stacked near its target’s height, with a leader to the target: the button glyph, the verb and an optional second line.
8. **Button hints**: Back and Select over a hairline, each with its button glyph. They are buttons too: Select runs the selected item, Back answers No in a dialog.

## When to use

- The System page of a game’s pause menu: a short, fixed list of verbs about the session (save, load, options, quit to title) moved through with a pad or the keys.
- A menu where one item is always selected and the selection itself is the cursor: hovering or the stick moves it, A or Enter runs it.
- Verbs that change the session and must ask first, with the safe answer chosen by default on the destructive ones (Load, Return to Title).
- Showing the controls next to the menu, as a console diagram with callouts paged by scheme (basic, combat, travel).
- Items that unlock over the game: a locked one stays in its place as “? ? ?” and refuses with a shake.

## When not to use

- An app or website’s settings. Use a settings page with labelled form controls; this is a game screen on its own dark plate.
- A long or searchable list of choices. Use a list with a filter or a command palette.
- A menu opened from a button in a toolbar. Use a dropdown menu with role="menu".
- Choosing between save files. Use save slots, which show each file’s place, time and progress.
- Teaching one input at the moment it is needed. Use a controller prompt next to the action, not a whole controls page.

## Installation

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

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

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

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

## Usage

```tsx
import { SystemMenu } from './system-menu';

export function PauseSystem({ save, load, quit }: { save(): void; load(): void; quit(): void }) {
  return (
    <SystemMenu
      autoplay={false}
      onConfirm={(id) => {
        if (id === 'save') save();
        if (id === 'load') load();
        if (id === 'return-to-title') quit();
      }}
    />
  );
}
```

## States

- **Rest**: `selected={0}`: Save wears the frame on the Basic Controls page. Passing `selected` turns the demo off.
- **Selection moved**: `selected={3}`: the frame on Ability Controls, in the second group.
- **Combat page**: `page={1}`: six callouts, two of them run across and down to their target’s centre, ending on a dot.
- **Travel page**: `page={2}`: a callout with a second line, “[Press] Look Around”, under its verb.
- **Asking**: `defaultDialog="save"`: the dialog over a dimmed plate, Yes chosen. Yes runs a bar, then a check and “Progress saved.”
- **Asking, destructive**: `defaultDialog="return-to-title"`: a `danger` item, so the answer starts on No.
- **Locked item**: An item with `locked`: drawn as “? ? ?” on a grey plate, named “Locked”, and it shakes instead of running.
- **Stacked**: Under 720 px wide: the list above in real pixels, centred, the diagram below at its own scale.
- **Compact**: Under 488 px: the console alone with numbered cyan markers, and the callouts listed underneath with the same numbers.
- **Light page**: `colorScheme="light"`: the plate keeps its colours; only its shadow is warm and soft.
- **Dark page**: `colorScheme="dark"`: a faint light edge round the plate and a deeper shadow.

## How it works

- Every size on the plate is a multiple of one design unit, --u, worked out from the component’s width with a container query unit (min(1px, 100cqi / 900)), so the screen scales as one drawing and keeps the kit’s proportions: 175 × 38 items on a 50-unit pitch with a 38-unit gap between groups, a 624 × 280 diagram, 158-unit callout plates and 14.75-unit italic type. Under 720 px the list stacks above the diagram and keeps real pixels; under 488 px the diagram keeps the console alone with numbered markers and lists the callouts underneath.
- The selected frame is one span over the item: a 1.5-unit cream border, four corner ticks drawn as small linear-gradient triangles, and an outer and inner glow as box-shadows. It is mounted only on the selected item, so moving the selection blooms a new frame in (scale 1.06 to 0.99 to 1 in 220 ms) and drops the old one; once it rests, its glow breathes on a 2.4-second alternate loop. A confirm remounts it with a dip and a cream flash; a locked item shakes its label instead.
- The controls diagram is laid out from data. Each callout names a target on the console (a stick, the face buttons, the d-pad, a shoulder key); the plates of each side are stacked at their target’s height and pushed down so they never overlap, and each leader runs from the plate’s cyan bar straight to the target’s edge when the target is level with the plate, or across and then up or down to its centre, ending in a dot.
- Paging remounts the diagram: plates open by clip-path from their bar outwards, staggered by 45 ms; leaders draw by stroke-dashoffset on a pathLength of 1; a cyan ring (a circle, a rounded diamond round the face buttons, or a pill round a shoulder key) pops in with an overshoot; the title slides in from the paging side. Hovering a plate thickens its leader and lights its ring.
- The console is a generic handheld drawn for the component in SVG: one body with two grips, a screen in a grey bezel, a d-pad over a stick on the left, four face buttons over a stick on the right and small minus and plus keys, with the same console seen from its top edge underneath, where the shoulder keys and triggers are. The callout glyphs share the lab’s game glyph style: light discs, keys and stick caps with dark letters.
- Confirming a verb with a question opens a dialog over the plate (a scrim, then a plate that scales up from 0.96) with Yes and No as framed items; destructive verbs start on No. Yes runs a 1.3-second bar, then a check pops with the done line, and the dialog closes itself. Uncontrolled, a demo walks the list, pages the controls twice, climbs back and saves, while the menu is on screen and untouched.

## API reference

- **items** (`SystemMenuItem[]`): The verbs, top to bottom: { id, label, confirm?, working?, done?, danger?, locked?, group? }. A confirm question makes the verb ask first; danger starts the answer on No; locked shows "? ? ?" and refuses; a change of group leaves a gap.
- **pages** (`ControlsPage[]`): The controls diagram’s pages: { title, callouts }, each callout { side, target, lines: [{ glyph?, label, sub? }] } pointing at a console target (ls, rs, dpad, face, l, zl, r, zr, minus, plus).
- **selected** (`number`): The selected item’s index, controlled. Turns the demo off.
- **defaultSelected** (`number`, default `0`): Uncontrolled, the item selected at first.
- **onSelectedChange** (`(index: number) => void`): Called with the new index when the selection moves.
- **page** (`number`): The controls page shown, controlled. Turns the demo off.
- **defaultPage** (`number`, default `0`): Uncontrolled, the controls page shown at first.
- **onPageChange** (`(page: number) => void`): Called with the new page index when the controls are paged.
- **onConfirm** (`(id: string) => void`): Called with an item’s id when it runs: at once, or after its dialog’s Yes.
- **defaultDialog** (`string`): An item’s id: opens with its confirm dialog asking, its default answer chosen, and that item selected. Turns the demo off.
- **autoplay** (`boolean`, default `true`): Plays the demo while the menu is on screen and untouched; any real input hands it to the reader.
- **colorScheme** (`'light' | 'dark' | 'system'`): Forces a colour scheme for the plate’s edge and shadow; inherited from the page when unset.
- **className** (`string`): A class for the root.

## Accessibility

- The list is a navigation landmark of real buttons with one tab stop (a roving tabindex): the arrow keys or W and S move the selection and the focus together, Home and End jump to the ends, and Enter, Space or A confirms. The selected item carries aria-current; verbs that ask first carry aria-haspopup="dialog"; a locked item is aria-disabled and named "Locked".
- The confirm dialog is a modal dialog labelled by its question. Focus moves to its default answer when the reader opens it, the arrow keys switch answers, Escape or B answers No, and focus returns to the item when it closes. Its working and done lines are announced through a polite live region, as are selection moves and page changes made by the reader (never the demo’s).
- The controls are a labelled section; Q and E or the left and right arrows page it, and the flourishes beside the title are buttons named "Previous controls page" and "Next controls page". Each callout is a list item with its verb and the button’s name in text; the console drawing, leaders and rings are aria-hidden.
- Focus shows as a blue outline outside the frame. Under reduced motion, the frame, plates, rings, leaders and dialog only fade, the label never shakes and nothing breathes; the demo does not play.

## Source

### system-menu/system-menu.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useEffectEvent,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
} from 'react';
import {
  Console,
  Flourish,
  Glyph,
  glyphName,
  TARGETS,
  type ConsoleTarget,
  type ControlGlyph,
} from './glyphs';
import { useAutoplay } from './use-autoplay';
import styles from './system-menu.module.css';

/*
 * System menu: the pause screen's "System" page. On the left, the vertical list of system verbs in
 * two groups (Save, Load / Options, Ability Controls, Extras, Return to Title); on the right, a
 * diagram of a handheld console with callouts for what each button does, paged like the game's
 * controls pages.
 *
 * - Exactly one item is selected. The selected one wears the bright frame: a cream outline with
 *   small corner ticks, a soft glow inside and out that breathes slowly while it rests. Moving the
 *   selection blooms the new frame in (a scale from 1.05 with a settle) and drops the old one.
 * - Items move with the arrow keys (or W / S), the pointer (hovering selects, as a cursor does in a
 *   game) and a demo. The list is one tab stop (roving tabindex): focus follows the selection.
 * - Confirming (Enter, Space, A, a click) dips and flashes the item. Verbs that change the session
 *   (Save, Load, Return to Title) ask first in a small dialog with its own framed Yes / No; Yes
 *   runs a progress bar, then a check and a done line, and the dialog closes itself. Destructive
 *   verbs default to No. A locked item (shown as "? ? ?") refuses with a short shake.
 * - The controls diagram is laid out from data: each callout points at a target on the console;
 *   plates on each side are stacked near their target's height without overlapping, and a leader
 *   runs from the plate's cyan bar straight to the target, or across then up or down to its centre.
 *   Paging (Q / E, the arrow keys left and right, or the flourishes by the title) wipes the new
 *   plates out of their bars, draws the leaders and pops a cyan ring round each target.
 * - On a narrow stage the diagram keeps the console alone, with numbered cyan markers on the
 *   targets, and lists the callouts underneath with the same numbers.
 * - Uncontrolled, a demo walks the list, pages the controls and saves, while the menu is on screen
 *   and untouched; any real input hands it to the reader.
 */

export type SystemMenuItem = {
  id: string;
  label: string;
  /** A question asked before the verb runs; without one, confirming runs it at once. */
  confirm?: string;
  /** What the dialog says while the verb runs, and once it is done. */
  working?: string;
  done?: string;
  /** Destructive: the dialog's answer starts on No. */
  danger?: boolean;
  /** Not unlocked yet: shown as "? ? ?" and cannot be confirmed. */
  locked?: boolean;
  /** Items are drawn in groups, with a gap between them. */
  group?: number;
};

export type ControlLine = { glyph?: ControlGlyph; label: string; sub?: string };
export type ControlCallout = {
  side: 'left' | 'right';
  target: ConsoleTarget;
  lines: ControlLine[];
};
export type ControlsPage = { title: string; callouts: ControlCallout[] };

const DEFAULT_ITEMS: SystemMenuItem[] = [
  {
    id: 'save',
    label: 'Save',
    confirm: 'Save your progress?',
    working: 'Saving…',
    done: 'Progress saved.',
    group: 0,
  },
  {
    id: 'load',
    label: 'Load',
    confirm: 'Load your last save? Unsaved progress will be lost.',
    working: 'Loading…',
    done: 'Save loaded.',
    danger: true,
    group: 0,
  },
  { id: 'options', label: 'Options', group: 1 },
  { id: 'ability-controls', label: 'Ability Controls', group: 1 },
  { id: 'extras', label: 'Extras', group: 1 },
  {
    id: 'return-to-title',
    label: 'Return to Title',
    confirm: 'Return to the title screen? Unsaved progress will be lost.',
    working: 'Returning to the title…',
    danger: true,
    group: 1,
  },
];

const DEFAULT_PAGES: ControlsPage[] = [
  {
    title: 'Basic Controls',
    callouts: [
      { side: 'left', target: 'zl', lines: [{ label: 'Focus', glyph: 'ZL' }] },
      {
        side: 'left',
        target: 'ls',
        lines: [{ label: 'Move', glyph: 'ls', sub: '[Press] Crouch' }],
      },
      {
        side: 'right',
        target: 'face',
        lines: [
          { glyph: 'X', label: 'Jump' },
          { glyph: 'A', label: 'Action' },
          { glyph: 'B', label: 'Put Away' },
        ],
      },
      { side: 'right', target: 'rs', lines: [{ glyph: 'rs', label: 'Camera Controls' }] },
    ],
  },
  {
    title: 'Combat',
    callouts: [
      { side: 'left', target: 'zl', lines: [{ label: 'Guard', glyph: 'ZL' }] },
      { side: 'left', target: 'dpad', lines: [{ label: 'Switch Gear', glyph: 'dpad' }] },
      {
        side: 'left',
        target: 'ls',
        lines: [{ label: 'Move', glyph: 'ls', sub: '[Guard] Sidestep' }],
      },
      { side: 'right', target: 'zr', lines: [{ glyph: 'ZR', label: 'Aim Bow' }] },
      {
        side: 'right',
        target: 'face',
        lines: [
          { glyph: 'Y', label: 'Attack' },
          { glyph: 'X', label: 'Jump' },
        ],
      },
      { side: 'right', target: 'r', lines: [{ glyph: 'R', label: 'Throw' }] },
    ],
  },
  {
    title: 'Travel',
    callouts: [
      { side: 'left', target: 'minus', lines: [{ label: 'Map', glyph: '-' }] },
      { side: 'left', target: 'dpad', lines: [{ label: 'Call Mount', glyph: 'dpad' }] },
      { side: 'left', target: 'ls', lines: [{ label: 'Move', glyph: 'ls' }] },
      { side: 'right', target: 'plus', lines: [{ glyph: '+', label: 'Menu' }] },
      {
        side: 'right',
        target: 'face',
        lines: [
          { glyph: 'B', label: 'Sprint' },
          { glyph: 'X', label: 'Glide' },
          { glyph: 'A', label: 'Climb' },
        ],
      },
      {
        side: 'right',
        target: 'rs',
        lines: [{ glyph: 'rs', label: 'Camera', sub: '[Press] Look Around' }],
      },
    ],
  },
];

/* The diagram's design box, in units: 624 × 280, the console drawn at 0.92 with its top-left
   at (174, 26), plates 158 wide with their cyan bars 16 units clear of the console. */
const BOX = { width: 624, height: 280 };
const CONSOLE = { x: 174, y: 26, scale: 0.92 };
const BAR = { left: 158, right: 466 };
const LINE = 22;
const SUB = 18;
const PAD = 5;
const GAP = 8;

type Placed = {
  callout: ControlCallout;
  number: number;
  top: number;
  height: number;
  path: string;
  end: { x: number; y: number; dot: boolean };
};

function targetPoint(target: ConsoleTarget) {
  const t = TARGETS[target];
  return {
    x: CONSOLE.x + t.x * CONSOLE.scale,
    y: CONSOLE.y + t.y * CONSOLE.scale,
    r: t.r * CONSOLE.scale,
  };
}

/** Stacks each side's plates near their target's height, then routes a leader to each target. */
function layout(page: ControlsPage): Placed[] {
  const placed: Placed[] = [];
  const numbered = page.callouts.map((callout, index) => ({ callout, number: index + 1 }));
  for (const side of ['left', 'right'] as const) {
    const list = numbered
      .filter(({ callout }) => callout.side === side)
      .map((entry) => {
        const height =
          PAD * 2 + entry.callout.lines.reduce((sum, l) => sum + LINE + (l.sub ? SUB : 0), 0);
        return { ...entry, height, aim: targetPoint(entry.callout.target).y };
      })
      .sort((a, b) => a.aim - b.aim);
    let floor = 0;
    const tops = list.map((entry) => {
      const top = Math.max(floor, Math.round(entry.aim - entry.height / 2));
      floor = top + entry.height + GAP;
      return top;
    });
    // If the stack runs past the box's foot, lift it back up as a whole.
    const overflow = Math.max(0, floor - GAP - BOX.height);
    list.forEach((entry, i) => {
      const top = Math.max(0, tops[i] - overflow);
      const t = targetPoint(entry.callout.target);
      const bar = side === 'left' ? BAR.left : BAR.right;
      const inside = t.y >= top + 6 && t.y <= top + entry.height - 6;
      let path: string;
      let end: Placed['end'];
      if (inside) {
        // Level with the target: straight across to its edge.
        const x = side === 'left' ? t.x - t.r - 3 : t.x + t.r + 3;
        path = `M${bar} ${t.y.toFixed(1)}H${x.toFixed(1)}`;
        end = { x, y: t.y, dot: false };
      } else {
        // Across at the plate's middle, then up or down to the target's centre.
        const y = top + entry.height / 2;
        path = `M${bar} ${y.toFixed(1)}H${t.x.toFixed(1)}V${t.y.toFixed(1)}`;
        end = { x: t.x, y: t.y, dot: true };
      }
      placed.push({
        callout: entry.callout,
        number: entry.number,
        top,
        height: entry.height,
        path,
        end,
      });
    });
  }
  return placed.sort((a, b) => a.number - b.number);
}

/** The ring a target wears while a callout points at it, in the diagram's units. */
function Ring({ target, hot }: { target: ConsoleTarget; hot: boolean }) {
  const t = TARGETS[target];
  const p = targetPoint(target);
  const style = { '--x': `${p.x}px`, '--y': `${p.y}px` } as CSSProperties;
  if (t.ring === 'diamond') {
    const s = (t.r + 2) * CONSOLE.scale;
    return (
      <g transform={`rotate(45 ${p.x} ${p.y})`}>
        <rect
          className={styles.ring}
          data-hot={hot || undefined}
          style={style}
          x={p.x - s / Math.SQRT2}
          y={p.y - s / Math.SQRT2}
          width={(s * 2) / Math.SQRT2}
          height={(s * 2) / Math.SQRT2}
          rx={5}
        />
      </g>
    );
  }
  if (t.ring === 'key') {
    const w = (target.startsWith('z') ? 58 : 64) * CONSOLE.scale;
    const h = (target.startsWith('z') ? 22 : 13) * CONSOLE.scale;
    return (
      <rect
        className={styles.ring}
        data-hot={hot || undefined}
        style={style}
        x={p.x - w / 2}
        y={p.y - h / 2}
        width={w}
        height={h}
        rx={h / 2}
      />
    );
  }
  return (
    <circle
      className={styles.ring}
      data-hot={hot || undefined}
      style={style}
      cx={p.x}
      cy={p.y}
      r={p.r + 2.5}
    />
  );
}

type Dialog = {
  index: number;
  /** 0: Yes, 1: No. */
  choice: 0 | 1;
  phase: 'ask' | 'working' | 'done';
};

/** The demo: a walk down the list, two pages of controls, back up, and a save. */
type DemoStep = { at: number; move?: number; page?: number; confirm?: boolean; yes?: boolean };
const DEMO: DemoStep[] = [
  { at: 900, move: 1 },
  { at: 1700, move: 1 },
  { at: 2500, move: 1 },
  { at: 3600, page: 1 },
  { at: 5400, page: 1 },
  { at: 7000, move: -1 },
  { at: 7700, move: -1 },
  { at: 8400, move: -1 },
  { at: 9400, confirm: true },
  { at: 10600, yes: true },
  { at: 14200, page: 1 },
];
const DEMO_LENGTH = 15600;
const WORKING_MS = 1300;
const DONE_MS = 1100;

export function SystemMenu({
  items = DEFAULT_ITEMS,
  pages = DEFAULT_PAGES,
  selected,
  defaultSelected = 0,
  onSelectedChange,
  page,
  defaultPage = 0,
  onPageChange,
  onConfirm,
  defaultDialog,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The system verbs, top to bottom; group numbers split them into blocks. */
  items?: SystemMenuItem[];
  /** The controls diagram's pages: a title and callouts that point at the console. */
  pages?: ControlsPage[];
  /** The selected item's index, controlled. Turns the demo off. */
  selected?: number;
  /** Uncontrolled, the item selected at first. */
  defaultSelected?: number;
  /** Called with the new index when the selection moves. */
  onSelectedChange?: (index: number) => void;
  /** The controls page shown, controlled. Turns the demo off. */
  page?: number;
  /** Uncontrolled, the controls page shown at first. */
  defaultPage?: number;
  /** Called with the new page index when the controls are paged. */
  onPageChange?: (page: number) => void;
  /** Called with an item's id when it runs: at once, or after its dialog's Yes. */
  onConfirm?: (id: string) => void;
  /** Opens with this item's confirm dialog asking, its default answer chosen. Turns the demo off. */
  defaultDialog?: string;
  /** Play the demo while on screen and untouched (default true). */
  autoplay?: boolean;
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const id = useId();
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(7000);
  const controlled = selected !== undefined || page !== undefined;
  const demo = autoplay && playing && !controlled && defaultDialog === undefined;

  const [openAt] = useState(() =>
    items.findIndex((item) => item.id === defaultDialog && item.confirm && !item.locked),
  );
  const [ownSelected, setOwnSelected] = useState(openAt < 0 ? defaultSelected : openAt);
  const [ownPage, setOwnPage] = useState(defaultPage);
  const current = Math.min(Math.max(selected ?? ownSelected, 0), items.length - 1);
  const pageIndex = (((page ?? ownPage) % pages.length) + pages.length) % pages.length;
  const [pageDirection, setPageDirection] = useState<1 | -1>(1);
  const [dialog, setDialog] = useState<Dialog | null>(() =>
    openAt < 0 ? null : { index: openAt, choice: items[openAt].danger ? 1 : 0, phase: 'ask' },
  );
  const [press, setPress] = useState({ index: -1, key: 0, refused: false });
  const [hot, setHot] = useState<number | null>(null);
  const [compact, setCompact] = useState(false);
  const [live, setLive] = useState('');

  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const choiceRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const fromReader = useRef(false);

  // The diagram's narrow form, when the component is under 520 px wide.
  useEffect(() => {
    const element = root.current;
    if (!element) return;
    const observer = new ResizeObserver(([entry]) =>
      setCompact(entry.contentBoxSize[0].inlineSize < 488),
    );
    observer.observe(element);
    return () => observer.disconnect();
  }, [root]);

  const select = useCallback(
    (index: number, focus: boolean) => {
      const next = ((index % items.length) + items.length) % items.length;
      setOwnSelected(next);
      setPress((p) => ({ ...p, index: -1 }));
      onSelectedChange?.(next);
      if (focus) itemRefs.current[next]?.focus();
      if (fromReader.current) {
        const item = items[next];
        setLive(`${item.locked ? 'Locked' : item.label}, ${next + 1} of ${items.length}`);
      }
    },
    [items, onSelectedChange],
  );

  const turnPage = useCallback(
    (step: 1 | -1) => {
      const next = (((pageIndex + step) % pages.length) + pages.length) % pages.length;
      setPageDirection(step);
      setOwnPage(next);
      onPageChange?.(next);
      setHot(null);
      if (fromReader.current) setLive(`Controls: ${pages[next].title}`);
    },
    [pageIndex, pages, onPageChange],
  );

  const confirm = useCallback(
    (index: number) => {
      const item = items[index];
      setPress((p) => ({ index, key: p.key + 1, refused: !!item.locked }));
      if (item.locked) {
        if (fromReader.current) setLive('Locked');
        return;
      }
      if (item.confirm) {
        setDialog({ index, choice: item.danger ? 1 : 0, phase: 'ask' });
        if (fromReader.current) setLive('');
      } else {
        onConfirm?.(item.id);
        if (fromReader.current) setLive(`${item.label} opened`);
      }
    },
    [items, onConfirm],
  );

  const answer = useCallback(
    (yes: boolean) => {
      setDialog((d) => {
        if (!d || d.phase !== 'ask') return d;
        if (!yes) return null;
        onConfirm?.(items[d.index].id);
        return { ...d, choice: 0, phase: 'working' };
      });
    },
    [items, onConfirm],
  );

  // A running verb: its bar, then its done line, then the dialog closes on its own.
  const working = dialog?.phase === 'working';
  const done = dialog?.phase === 'done';
  useEffect(() => {
    if (!working && !done) return;
    const timer = window.setTimeout(
      () => {
        setDialog((d) => {
          if (!d) return d;
          if (d.phase === 'working' && items[d.index].done) return { ...d, phase: 'done' };
          return null;
        });
      },
      working ? WORKING_MS : DONE_MS,
    );
    return () => window.clearTimeout(timer);
  }, [working, done, items]);

  // Announce the dialog's states to a reader who started it; keep their focus inside it while it
  // runs, and give it back to the item when it closes.
  const dialogItem = dialog ? items[dialog.index] : null;
  const dialogPhase = dialog?.phase;
  const dialogRef = useRef<HTMLDivElement>(null);
  useEffect(() => {
    if (!dialogItem || !fromReader.current) return;
    if (dialogPhase === 'working') setLive(dialogItem.working ?? '');
    if (dialogPhase === 'done') setLive(dialogItem.done ?? '');
    if (dialogPhase !== 'ask') dialogRef.current?.focus();
  }, [dialogPhase, dialogItem]);

  const dialogOpen = dialog !== null;
  const wasOpen = useRef(false);
  const openChoice = dialog?.choice ?? 0;
  useEffect(() => {
    if (dialogOpen !== wasOpen.current && fromReader.current) {
      if (dialogOpen) choiceRefs.current[openChoice]?.focus();
      else itemRefs.current[current]?.focus();
    }
    wasOpen.current = dialogOpen;
    // Only on opening and closing.
  }, [dialogOpen]);

  // The demo.
  const runStep = useEffectEvent((step: DemoStep) => {
    fromReader.current = false;
    if (step.move) select(current + step.move, false);
    if (step.page) turnPage(1);
    if (step.confirm) confirm(current);
    if (step.yes) answer(true);
  });
  const resetDemo = useEffectEvent(() => {
    setDialog(null);
    select(0, false);
  });
  useEffect(() => {
    if (!demo) return;
    let timers: number[] = [];
    const run = () => {
      resetDemo();
      timers = DEMO.map((step) => window.setTimeout(() => runStep(step), step.at));
      timers.push(window.setTimeout(run, DEMO_LENGTH));
    };
    run();
    return () => timers.forEach((t) => window.clearTimeout(t));
  }, [demo]);

  const reader = () => {
    fromReader.current = true;
    take();
  };

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    if (event.metaKey || event.ctrlKey || event.altKey) return;
    const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
    reader();
    if (dialog) {
      if (dialog.phase !== 'ask') return;
      if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'w', 's'].includes(key)) {
        event.preventDefault();
        const choice = dialog.choice === 0 ? 1 : 0;
        setDialog({ ...dialog, choice });
        choiceRefs.current[choice]?.focus();
      } else if (key === 'Escape' || key === 'b') {
        event.preventDefault();
        answer(false);
      } else if (key === 'a') {
        event.preventDefault();
        answer(dialog.choice === 0);
      }
      return;
    }
    if (key === 'ArrowDown' || key === 's') {
      event.preventDefault();
      select(current + 1, true);
    } else if (key === 'ArrowUp' || key === 'w') {
      event.preventDefault();
      select(current - 1, true);
    } else if (key === 'Home' || key === 'End') {
      event.preventDefault();
      select(key === 'Home' ? 0 : items.length - 1, true);
    } else if (key === 'ArrowLeft' || key === 'q') {
      event.preventDefault();
      turnPage(-1);
    } else if (key === 'ArrowRight' || key === 'e') {
      event.preventDefault();
      turnPage(1);
    } else if (key === 'a') {
      event.preventDefault();
      confirm(current);
    }
  };

  const controls = pages[pageIndex];
  const placed = layout(controls);
  const titleId = `${id}-title`;
  const dialogTitle = `${id}-dialog`;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      data-compact={compact || undefined}
      onKeyDown={onKeyDown}
      onPointerDown={reader}
      onWheel={take}
    >
      <div className={styles.panel} data-part="panel">
        <div className={styles.layout}>
          <nav className={styles.menu} aria-label="System" data-part="menu">
            <ul className={styles.list}>
              {items.map((item, index) => {
                const isSelected = index === current;
                const gap = index > 0 && (item.group ?? 0) !== (items[index - 1].group ?? 0);
                return (
                  <li key={item.id} className={styles.entry} data-gap={gap || undefined}>
                    <button
                      ref={(element) => {
                        itemRefs.current[index] = element;
                      }}
                      type="button"
                      className={styles.item}
                      data-part="item"
                      data-selected={isSelected || undefined}
                      data-locked={item.locked || undefined}
                      aria-current={isSelected ? 'true' : undefined}
                      aria-disabled={item.locked || undefined}
                      aria-haspopup={item.confirm ? 'dialog' : undefined}
                      aria-label={item.locked ? 'Locked' : undefined}
                      tabIndex={isSelected ? 0 : -1}
                      onPointerEnter={(event) => {
                        if (event.pointerType !== 'mouse' || dialog) return;
                        reader();
                        if (!isSelected) select(index, false);
                      }}
                      onFocus={() => {
                        if (!isSelected && fromReader.current) select(index, false);
                      }}
                      onClick={() => {
                        reader();
                        if (!isSelected) select(index, false);
                        confirm(index);
                      }}
                    >
                      {isSelected && (
                        <span
                          key={`${index}-${press.key}`}
                          className={styles.frame}
                          data-part="frame"
                          data-pressed={press.index === index && press.key > 0 ? '' : undefined}
                          aria-hidden="true"
                        />
                      )}
                      <span
                        key={press.index === index ? press.key : 0}
                        className={styles.label}
                        data-pressed={
                          press.index === index && press.key > 0 && !press.refused ? '' : undefined
                        }
                        data-refused={press.index === index && press.refused ? '' : undefined}
                      >
                        {item.locked ? '? ? ?' : item.label}
                      </span>
                    </button>
                  </li>
                );
              })}
            </ul>
          </nav>

          <section className={styles.controls} aria-labelledby={titleId} data-part="controls">
            <header className={styles.heading} data-part="heading">
              <button
                type="button"
                className={styles.flourish}
                aria-label="Previous controls page"
                onClick={() => {
                  reader();
                  turnPage(-1);
                }}
              >
                <Flourish />
              </button>
              <h3 className={styles.title} id={titleId}>
                <span key={pageIndex} className={styles.titleText} data-direction={pageDirection}>
                  {controls.title}
                </span>
              </h3>
              <button
                type="button"
                className={styles.flourish}
                data-next=""
                aria-label="Next controls page"
                onClick={() => {
                  reader();
                  turnPage(1);
                }}
              >
                <Flourish />
              </button>
            </header>
            <div className={styles.dots} aria-hidden="true">
              {pages.map((p, i) => (
                <span key={p.title} data-on={i === pageIndex || undefined} />
              ))}
            </div>

            <div className={styles.diagram} key={pageIndex}>
              <svg
                className={styles.art}
                viewBox={
                  compact
                    ? `${CONSOLE.x - 6} ${CONSOLE.y - 6} ${300 * CONSOLE.scale + 12} ${256 * CONSOLE.scale + 12}`
                    : `0 0 ${BOX.width} ${BOX.height}`
                }
                aria-hidden="true"
                data-part="diagram"
              >
                <g transform={`translate(${CONSOLE.x} ${CONSOLE.y}) scale(${CONSOLE.scale})`}>
                  <Console />
                </g>
                {placed.map((p, i) => (
                  <g key={p.number} style={{ '--i': i } as CSSProperties}>
                    <Ring target={p.callout.target} hot={hot === p.number} />
                    {!compact && (
                      <>
                        <path
                          className={styles.leader}
                          d={p.path}
                          pathLength={1}
                          data-hot={hot === p.number || undefined}
                        />
                        {p.end.dot && (
                          <circle className={styles.dot} cx={p.end.x} cy={p.end.y} r={2.6} />
                        )}
                      </>
                    )}
                  </g>
                ))}
                {compact &&
                  // One marker per target, numbered after the first callout pointing at it.
                  placed
                    .filter(
                      (p, i) =>
                        placed.findIndex((q) => q.callout.target === p.callout.target) === i,
                    )
                    .map((p, _, unique) => {
                      // Targets stacked close together (a trigger over its bumper) would hide
                      // each other's marker: a later one steps toward the console's middle.
                      const centre = CONSOLE.x + 138;
                      const spot = (q: Placed) => {
                        const t = targetPoint(q.callout.target);
                        return {
                          x: Math.min(Math.max(t.x, CONSOLE.x + 4), CONSOLE.x + 272),
                          y: t.y,
                        };
                      };
                      let { x } = spot(p);
                      const { y } = spot(p);
                      for (const q of unique.slice(0, unique.indexOf(p))) {
                        const o = spot(q);
                        if (Math.hypot(o.x - x, o.y - y) < 17) x += o.x > centre ? -18 : 18;
                      }
                      return (
                        <g key={p.number} className={styles.marker}>
                          <circle cx={x} cy={y} r={7.5} />
                          <text x={x} y={y + 3.6} textAnchor="middle">
                            {p.number}
                          </text>
                        </g>
                      );
                    })}
              </svg>
              <ul className={styles.callouts} aria-label={`${controls.title} controls`}>
                {placed.map((p, i) => (
                  <li
                    key={p.number}
                    className={styles.plate}
                    data-part="callout"
                    data-side={p.callout.side}
                    style={
                      {
                        '--top': p.top,
                        '--height': p.height,
                        '--i': i,
                      } as CSSProperties
                    }
                    onPointerEnter={() => setHot(p.number)}
                    onPointerLeave={() => setHot((h) => (h === p.number ? null : h))}
                  >
                    {compact && (
                      <span className={styles.number} aria-hidden="true">
                        {placed.find((q) => q.callout.target === p.callout.target)?.number}
                      </span>
                    )}
                    <span className={styles.lines}>
                      {p.callout.lines.map((line) => (
                        <span key={line.label} className={styles.lineGroup}>
                          <span className={styles.line}>
                            {line.glyph && (
                              <span className={styles.glyph}>
                                <Glyph glyph={line.glyph} />
                              </span>
                            )}
                            <span className={styles.verb}>{line.label}</span>
                            {line.glyph && (
                              <span className={styles.srOnly}>: {glyphName(line.glyph)}</span>
                            )}
                          </span>
                          {line.sub && <span className={styles.sub}>{line.sub}</span>}
                        </span>
                      ))}
                    </span>
                  </li>
                ))}
              </ul>
            </div>
          </section>
        </div>

        <footer className={styles.footer} data-part="hints">
          <button
            type="button"
            className={styles.hint}
            data-part="hint"
            onClick={() => {
              reader();
              if (dialog) answer(false);
            }}
          >
            <span>Back</span>
            <span className={styles.hintGlyph}>
              <Glyph glyph="B" />
            </span>
          </button>
          <button
            type="button"
            className={styles.hint}
            onClick={() => {
              reader();
              if (dialog) answer(dialog.choice === 0);
              else confirm(current);
            }}
          >
            <span>Select</span>
            <span className={styles.hintGlyph}>
              <Glyph glyph="A" />
            </span>
          </button>
        </footer>

        {dialog && dialogItem && (
          <div className={styles.scrim} data-part="dialog">
            <div
              ref={dialogRef}
              className={styles.dialog}
              role="dialog"
              tabIndex={-1}
              aria-modal="true"
              aria-labelledby={dialogTitle}
            >
              <p className={styles.question} id={dialogTitle}>
                {dialog.phase === 'ask'
                  ? dialogItem.confirm
                  : dialog.phase === 'working'
                    ? dialogItem.working
                    : dialogItem.done}
              </p>
              {dialog.phase === 'ask' && (
                <div className={styles.choices}>
                  {(['Yes', 'No'] as const).map((word, choice) => (
                    <button
                      key={word}
                      ref={(element) => {
                        choiceRefs.current[choice] = element;
                      }}
                      type="button"
                      className={styles.item}
                      data-part="choice"
                      data-choice=""
                      data-selected={dialog.choice === choice || undefined}
                      tabIndex={dialog.choice === choice ? 0 : -1}
                      onPointerEnter={(event) => {
                        if (event.pointerType !== 'mouse') return;
                        setDialog((d) => (d ? { ...d, choice: choice as 0 | 1 } : d));
                      }}
                      onClick={() => {
                        reader();
                        answer(choice === 0);
                      }}
                    >
                      {dialog.choice === choice && (
                        <span className={styles.frame} aria-hidden="true" />
                      )}
                      <span className={styles.label}>{word}</span>
                    </button>
                  ))}
                </div>
              )}
              {dialog.phase === 'working' && (
                <span className={styles.progress} aria-hidden="true">
                  <span />
                </span>
              )}
              {dialog.phase === 'done' && (
                <svg className={styles.check} viewBox="0 0 24 24" aria-hidden="true">
                  <circle cx="12" cy="12" r="11" />
                  <path d="M7 12.4l3.3 3.3L17.2 8.6" />
                </svg>
              )}
            </div>
          </div>
        )}
      </div>
      <p className={styles.srOnly} aria-live="polite">
        {live}
      </p>
    </div>
  );
}
```

### system-menu/system-menu.module.css

```css
/* System menu (system-menu.tsx): the pause screen's system list beside a controls diagram, on a
   dark plate of its own.

   Every size inside the plate is a multiple of --u, one design unit (1 px at full size), set from
   the component's width with a container query unit, so the screen scales as one drawing and keeps
   the kit's proportions: 175 × 38 items on a 50-unit pitch, a 624 × 280 diagram, 158-unit callout
   plates. Under 720 px the list stacks above the diagram (the list then keeps real pixels); under
   488 px the diagram keeps the console alone with numbered markers and lists the callouts below.

   Colours are the tokens below. The game's pieces keep their colours, as a game's do: the plate,
   the near-black items, the cream frame and type, the cyan callouts, the light console. Only what
   touches the page follows the scheme, through light-dark(): the plate's edge and shadow.

   Motion: the frame blooms in on selection (scale and glow) and breathes while it rests; a press
   remounts it with a dip and a flash; a refusal shakes the label. Paging wipes the plates out of
   their bars by clip-path, draws the leaders by stroke-dashoffset and pops the targets' rings. */

.root {
  /* The game screen. */
  --plate-top: #1d1e1a;
  --plate-bottom: #10110f;
  --rule: rgb(236 232 218 / 0.16);
  --ink: #ebe7d9;
  --ink-dim: rgb(235 231 217 / 0.4);
  --item: #121310;
  --item-top: #171814;
  --item-edge: #2a2d27;
  --frame: #f1ebd3;
  --glow: rgb(255 244 208 / 0.55);
  --glow-in: rgb(255 244 208 / 0.22);
  --locked: #45443f;
  --locked-edge: #55534d;
  --locked-ink: #6f6d66;
  --cyan: #3cd3fc;
  --callout: rgb(36 78 92 / 0.86);
  --glyph: #e1ded1;
  --glyph-ink: #24221d;
  --console-body: #e3e3e3;
  --console-edge: #bdbdbd;
  --console-bezel: #aaaaaa;
  --console-screen: #262626;
  --console-control: #808080;
  --console-control-dark: #666666;
  --console-key: #d2d2d2;
  --console-key-back: #b4b4b4;
  --console-trigger: #8a8a8a;
  --focus: #8db4ff;
  /* On the page itself. */
  --edge: rgb(0 0 0 / 0);
  --shadow: rgb(30 26 18 / 0.28);
  position: relative;
  container-type: inline-size;
  display: grid;
  justify-items: center;
  align-content: center;
  box-sizing: border-box;
  width: 100%;
  min-height: 400px;
  padding: 24px 16px;
  color: var(--ink);
  font-family:
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --edge: light-dark(rgb(0 0 0 / 0), rgb(255 255 255 / 0.1));
    --shadow: light-dark(rgb(30 26 18 / 0.28), rgb(0 0 0 / 0.6));
  }
}
/* 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;
}

/* The plate: the screen's dark ground, so the frame's glow and the console read on any page. */
.panel {
  --u: min(1px, 100cqi / 900);
  --lu: var(--u);
  position: relative;
  box-sizing: border-box;
  padding: calc(26 * var(--u)) calc(30 * var(--u)) 0;
  border-radius: calc(14 * var(--u));
  background: linear-gradient(180deg, var(--plate-top), var(--plate-bottom));
  box-shadow:
    inset 0 0 0 1px var(--edge),
    0 calc(14 * var(--u)) calc(36 * var(--u)) calc(-12 * var(--u)) var(--shadow);
}
.layout {
  display: grid;
  grid-template-columns: calc(175 * var(--u)) calc(624 * var(--u));
  column-gap: calc(40 * var(--u));
  align-items: start;
}

/* The list: two groups, items on a 50-unit pitch. */
.menu {
  padding-top: calc(28 * var(--u));
}
.list {
  display: grid;
  gap: calc(12 * var(--lu));
  margin: 0;
  padding: 0;
  list-style: none;
}
.entry[data-gap] {
  margin-top: calc(38 * var(--lu));
}

/* An item: a near-black plate with a faint edge and italic cream type. */
.item {
  position: relative;
  display: grid;
  place-items: center;
  box-sizing: border-box;
  width: calc(175 * var(--lu));
  height: calc(38 * var(--lu));
  margin: 0;
  padding: 0 calc(10 * var(--lu));
  border: 0;
  border-radius: calc(3 * var(--lu));
  background: linear-gradient(180deg, var(--item-top), var(--item));
  box-shadow:
    inset 0 0 0 calc(1 * var(--lu)) var(--item-edge),
    0 calc(1 * var(--lu)) calc(3 * var(--lu)) rgb(0 0 0 / 0.35);
  color: var(--ink);
  font: inherit;
  font-size: calc(14.75 * var(--lu));
  font-style: italic;
  font-weight: 500;
  letter-spacing: 0.005em;
  cursor: pointer;
  touch-action: manipulation;
  outline: none;
}
.item:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: calc(5 * var(--lu));
}
.item[data-locked] {
  background: var(--locked);
  box-shadow: inset 0 0 0 calc(1 * var(--lu)) var(--locked-edge);
  color: var(--locked-ink);
  cursor: default;
}
.label {
  position: relative;
  overflow: hidden;
  max-width: 100%;
  line-height: 1.2;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.label[data-pressed] {
  animation: flash 380ms ease-out;
}
.label[data-refused] {
  animation: shake 340ms cubic-bezier(0.36, 0.07, 0.19, 0.97);
}
@keyframes flash {
  20% {
    color: #fff;
    text-shadow: 0 0 calc(8 * var(--lu)) var(--glow);
  }
}
@keyframes shake {
  20%,
  60% {
    translate: calc(-5 * var(--lu)) 0;
  }
  40%,
  80% {
    translate: calc(5 * var(--lu)) 0;
  }
}

/* The selected frame: a cream outline with a tick in each corner, a glow outside and in. */
.frame {
  position: absolute;
  inset: calc(-1 * var(--lu));
  border: calc(1.5 * var(--lu)) solid var(--frame);
  border-radius: calc(4 * var(--lu));
  background:
    linear-gradient(135deg, var(--frame) 50%, transparent 50%) top left / calc(6 * var(--lu))
      calc(6 * var(--lu)) no-repeat,
    linear-gradient(225deg, var(--frame) 50%, transparent 50%) top right / calc(6 * var(--lu))
      calc(6 * var(--lu)) no-repeat,
    linear-gradient(45deg, var(--frame) 50%, transparent 50%) bottom left / calc(6 * var(--lu))
      calc(6 * var(--lu)) no-repeat,
    linear-gradient(315deg, var(--frame) 50%, transparent 50%) bottom right / calc(6 * var(--lu))
      calc(6 * var(--lu)) no-repeat;
  box-shadow:
    0 0 calc(7 * var(--lu)) calc(1 * var(--lu)) var(--glow),
    inset 0 0 calc(10 * var(--lu)) var(--glow-in);
  pointer-events: none;
  animation:
    frame-in 220ms cubic-bezier(0.2, 0.9, 0.3, 1.2) both,
    breathe 2400ms ease-in-out 400ms infinite alternate;
}
.frame[data-pressed] {
  animation:
    press 300ms cubic-bezier(0.3, 0.7, 0.4, 1) both,
    breathe 2400ms ease-in-out 600ms infinite alternate;
}
@keyframes frame-in {
  from {
    opacity: 0;
    scale: 1.06;
  }
  55% {
    opacity: 1;
    scale: 0.99;
  }
}
@keyframes press {
  from {
    scale: 0.96;
    background-color: rgb(255 244 208 / 0.3);
  }
  to {
    background-color: rgb(255 244 208 / 0);
  }
}
@keyframes breathe {
  to {
    box-shadow:
      0 0 calc(11 * var(--lu)) calc(2 * var(--lu)) var(--glow),
      inset 0 0 calc(13 * var(--lu)) var(--glow-in);
  }
}

/* The controls: a title between two flourishes, page dots, then the diagram. */
.controls {
  min-width: 0;
}
.heading {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: calc(12 * var(--u));
  height: calc(28 * var(--u));
}
.title {
  overflow: hidden;
  margin: 0;
  padding: 0 calc(4 * var(--u));
  font-size: calc(15.5 * var(--u));
  font-weight: 500;
  line-height: 1.3;
  white-space: nowrap;
}
.titleText {
  display: block;
  animation: title-in 260ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.titleText[data-direction='-1'] {
  --from: -1;
}
@keyframes title-in {
  from {
    opacity: 0;
    translate: calc(var(--from, 1) * 16 * var(--u)) 0;
  }
}
.flourish {
  display: grid;
  place-items: center;
  width: calc(28 * var(--u));
  height: calc(22 * var(--u));
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: calc(4 * var(--u));
  background: none;
  color: var(--ink);
  cursor: pointer;
  opacity: 0.86;
  transition:
    opacity 150ms,
    translate 150ms;
  touch-action: manipulation;
}
.flourish svg {
  width: calc(22 * var(--u));
  height: calc(11 * var(--u));
}
.flourish[data-next] svg {
  scale: -1 1;
}
.flourish:hover {
  opacity: 1;
  translate: calc(-2 * var(--u)) 0;
}
.flourish[data-next]:hover {
  translate: calc(2 * var(--u)) 0;
}
.flourish:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.dots {
  display: flex;
  justify-content: center;
  gap: calc(4 * var(--u));
  height: calc(6 * var(--u));
  margin-top: calc(1 * var(--u));
}
.dots span {
  width: calc(3.5 * var(--u));
  height: calc(3.5 * var(--u));
  border-radius: 50%;
  background: var(--ink-dim);
  transition: background-color 200ms;
}
.dots span[data-on] {
  background: var(--ink);
}

.diagram {
  position: relative;
  width: calc(624 * var(--u));
  height: calc(280 * var(--u));
  margin-top: calc(8 * var(--u));
}
.art {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
}

/* A target's ring, a leader and its end dot, all cyan. */
.ring {
  fill: none;
  stroke: var(--cyan);
  stroke-width: 1.4;
  transform-box: fill-box;
  transform-origin: center;
  animation: ring-in 320ms cubic-bezier(0.3, 1.5, 0.5, 1) calc(160ms + var(--i) * 50ms) both;
  transition:
    stroke-width 150ms,
    filter 150ms;
}
.ring[data-hot] {
  stroke-width: 2.2;
  filter: drop-shadow(0 0 2px var(--cyan));
}
@keyframes ring-in {
  from {
    opacity: 0;
    scale: 0.6;
  }
}
.leader {
  fill: none;
  stroke: var(--cyan);
  stroke-width: 1.5;
  stroke-dasharray: 1;
  animation: draw 280ms cubic-bezier(0.4, 0, 0.2, 1) calc(90ms + var(--i) * 50ms) both;
  transition: stroke-width 150ms;
}
.leader[data-hot] {
  stroke-width: 2.2;
}
@keyframes draw {
  from {
    stroke-dashoffset: 1;
  }
}
.dot {
  fill: var(--cyan);
  animation: fade-in 120ms ease-out calc(330ms + var(--i) * 50ms) both;
}
.marker circle {
  fill: var(--cyan);
}
.marker text {
  fill: #0d2a33;
  font-size: 10px;
  font-weight: 700;
}

/* The callouts: plates that fade out from their cyan bar, label and glyph on each line. */
.callouts {
  position: absolute;
  inset: 0;
  margin: 0;
  padding: 0;
  list-style: none;
}
.plate {
  position: absolute;
  top: calc(var(--top) * var(--u));
  left: 0;
  display: flex;
  justify-content: flex-end;
  box-sizing: border-box;
  width: calc(158 * var(--u));
  min-height: calc(var(--height) * var(--u));
  padding: calc(5 * var(--u)) calc(9 * var(--u)) calc(5 * var(--u)) 0;
  border-right: calc(2 * var(--u)) solid var(--cyan);
  background: linear-gradient(to left, var(--callout), rgb(36 78 92 / 0));
  animation: wipe-left 260ms cubic-bezier(0.2, 0.8, 0.2, 1) calc(var(--i) * 45ms) both;
}
.plate[data-side='right'] {
  left: calc(466 * var(--u));
  justify-content: flex-start;
  padding: calc(5 * var(--u)) 0 calc(5 * var(--u)) calc(9 * var(--u));
  border-right: 0;
  border-left: calc(2 * var(--u)) solid var(--cyan);
  background: linear-gradient(to right, var(--callout), rgb(36 78 92 / 0));
  animation-name: wipe-right;
}
@keyframes wipe-left {
  from {
    clip-path: inset(0 0 0 100%);
  }
  to {
    clip-path: inset(0 0 0 0);
  }
}
@keyframes wipe-right {
  from {
    clip-path: inset(0 100% 0 0);
  }
  to {
    clip-path: inset(0 0 0 0);
  }
}
.lines {
  display: grid;
  min-width: 0;
}
.lineGroup {
  display: grid;
  justify-items: end;
}
.plate[data-side='right'] .lineGroup {
  justify-items: start;
}
.line {
  display: flex;
  flex-direction: row-reverse;
  align-items: center;
  gap: calc(5 * var(--u));
  height: calc(22 * var(--u));
  font-size: calc(13.75 * var(--u));
  font-style: italic;
  font-weight: 500;
  white-space: nowrap;
}
.plate[data-side='right'] .line {
  flex-direction: row;
}
.sub {
  height: calc(18 * var(--u));
  color: var(--cyan);
  font-size: calc(12.75 * var(--u));
  line-height: calc(16 * var(--u));
  white-space: nowrap;
}
.glyph {
  display: grid;
  place-items: center;
  flex: none;
}
.glyph svg {
  display: block;
  height: calc(17 * var(--u));
  width: auto;
  overflow: visible;
}
.glyph svg[data-glyph='stick'] {
  height: calc(18 * var(--u));
}
.glyph svg[data-glyph='key'] {
  height: calc(14 * var(--u));
}
.number {
  display: none;
}

/* The hints along the foot, under a thin rule. */
.footer {
  display: flex;
  justify-content: flex-end;
  gap: calc(20 * var(--u));
  margin-top: calc(20 * var(--u));
  padding: calc(10 * var(--u)) 0 calc(12 * var(--u));
  border-top: 1px solid var(--rule);
}
.hint {
  display: inline-flex;
  align-items: center;
  gap: calc(6 * var(--u));
  min-height: 24px;
  margin: 0;
  padding: 0 calc(2 * var(--u));
  border: 0;
  border-radius: 4px;
  background: none;
  color: var(--ink);
  font: inherit;
  font-size: max(12px, calc(13.5 * var(--u)));
  font-style: italic;
  font-weight: 500;
  cursor: pointer;
  touch-action: manipulation;
}
.hint:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.hintGlyph svg {
  display: block;
  width: max(15px, calc(17 * var(--u)));
  height: max(15px, calc(17 * var(--u)));
}

/* The confirm dialog, over the whole plate. */
.scrim {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  padding: 16px;
  border-radius: inherit;
  background: rgb(8 9 7 / 0.74);
  animation: fade-in 160ms ease-out both;
}
.dialog {
  --lu: max(var(--u), 0.86px);
  display: grid;
  justify-items: center;
  gap: calc(16 * var(--lu));
  box-sizing: border-box;
  width: min(calc(400 * var(--lu)), 100%);
  padding: calc(22 * var(--lu)) calc(26 * var(--lu));
  border-block: 1px solid rgb(236 232 218 / 0.4);
  background: linear-gradient(
    90deg,
    rgb(18 19 16 / 0.6),
    rgb(18 19 16 / 0.97) 18%,
    rgb(18 19 16 / 0.97) 82%,
    rgb(18 19 16 / 0.6)
  );
  outline: none;
  animation: dialog-in 220ms cubic-bezier(0.2, 0.9, 0.3, 1.1) both;
}
@keyframes dialog-in {
  from {
    opacity: 0;
    scale: 0.96;
  }
}
.question {
  margin: 0;
  font-size: calc(16 * var(--lu));
  font-weight: 500;
  line-height: 1.4;
  text-align: center;
  text-wrap: balance;
}
.choices {
  display: grid;
  gap: calc(10 * var(--lu));
}
.item[data-choice] {
  width: calc(150 * var(--lu));
}
.progress {
  position: relative;
  overflow: hidden;
  width: calc(200 * var(--lu));
  height: calc(3 * var(--lu));
  border-radius: 99px;
  background: rgb(236 232 218 / 0.16);
}
.progress span {
  position: absolute;
  inset: 0;
  background: var(--frame);
  box-shadow: 0 0 calc(6 * var(--lu)) var(--glow);
  transform-origin: left;
  animation: fill 1300ms cubic-bezier(0.5, 0, 0.3, 1) both;
}
@keyframes fill {
  from {
    scale: 0 1;
  }
}
.check {
  width: calc(26 * var(--lu));
  height: calc(26 * var(--lu));
  animation: pop 360ms cubic-bezier(0.3, 1.6, 0.5, 1) both;
}
.check circle {
  fill: none;
  stroke: var(--frame);
  stroke-width: 1.5;
}
.check path {
  fill: none;
  stroke: var(--frame);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
@keyframes pop {
  from {
    opacity: 0;
    scale: 0.4;
  }
}
@keyframes fade-in {
  from {
    opacity: 0;
  }
}

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

/* Stacked: the list above, centred and in real pixels; the diagram below at its own scale. */
@container (max-width: 719px) {
  .panel {
    --u: min(1px, (100cqi - 32px) / 624);
    --lu: 1px;
    padding: 20px 16px 0;
    border-radius: 14px;
  }
  .layout {
    grid-template-columns: 100%;
    row-gap: 22px;
    justify-items: center;
  }
  .menu {
    padding-top: 0;
  }
  .list {
    gap: 10px;
  }
  .entry[data-gap] {
    margin-top: 16px;
  }
  .item:not([data-choice]) {
    width: min(240px, calc(100cqi - 64px));
  }
  .heading {
    height: 28px;
  }
  .title {
    font-size: max(15px, calc(15.5 * var(--u)));
  }
}

/* Compact: the console alone with numbered markers, the callouts in two columns underneath. */
.root[data-compact] .diagram {
  width: 100%;
  height: auto;
  margin-top: 10px;
}
.root[data-compact] .art {
  position: relative;
  display: block;
  width: min(100%, 300px);
  height: auto;
  margin-inline: auto;
}
.root[data-compact] .callouts {
  position: static;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 6px;
  margin-top: 14px;
}
.root[data-compact] .plate,
.root[data-compact] .plate[data-side='right'] {
  position: relative;
  top: auto;
  left: auto;
  align-items: flex-start;
  justify-content: flex-start;
  gap: 6px;
  width: auto;
  min-height: 0;
  padding: 4px 6px 4px 7px;
  border-right: 0;
  border-left: 2px solid var(--cyan);
  background: linear-gradient(to right, var(--callout), rgb(36 78 92 / 0.15));
  animation-name: wipe-right;
}
.root[data-compact] .number {
  display: grid;
  place-items: center;
  flex: none;
  width: 15px;
  height: 15px;
  margin-top: 3.5px;
  border-radius: 50%;
  background: var(--cyan);
  color: #0d2a33;
  font-size: 10px;
  font-weight: 700;
}
.root[data-compact] .lineGroup {
  justify-items: start;
}
.root[data-compact] .line,
.root[data-compact] .plate[data-side='right'] .line {
  flex-direction: row;
  gap: 5px;
  height: auto;
  min-height: 22px;
  font-size: 13px;
  white-space: normal;
}
.root[data-compact] .heading {
  gap: 10px;
}
.root[data-compact] .title {
  font-size: 15px;
}
.root[data-compact] .flourish {
  width: 26px;
  height: 22px;
}
.root[data-compact] .flourish svg {
  width: 20px;
  height: 10px;
}
.root[data-compact] .dots {
  gap: 4px;
  height: 6px;
}
.root[data-compact] .dots span {
  width: 4px;
  height: 4px;
}
.root[data-compact] .sub {
  height: auto;
  padding-bottom: 2px;
  font-size: 12px;
  line-height: 1.25;
  white-space: normal;
}
.root[data-compact] .glyph svg {
  height: 15px;
}
.root[data-compact] .glyph svg[data-glyph='stick'] {
  height: 16px;
}
.root[data-compact] .glyph svg[data-glyph='key'] {
  height: 12px;
}
@container (max-width: 300px) {
  .root[data-compact] .callouts {
    grid-template-columns: 100%;
  }
}
.root[data-compact] .footer {
  margin-top: 16px;
  padding: 10px 0 12px;
  gap: 16px;
}

/* Reduced motion: no blooms, shakes, wipes, line drawing or breathing; only fades. */
@media (prefers-reduced-motion: reduce) {
  .frame,
  .frame[data-pressed] {
    animation: fade-in 120ms ease-out both;
  }
  .label[data-pressed],
  .label[data-refused] {
    animation: none;
  }
  .titleText,
  .plate,
  .ring,
  .leader,
  .dialog,
  .check {
    animation: fade-in 140ms ease-out both;
  }
  .leader {
    stroke-dasharray: none;
  }
  .progress span {
    animation: fade-in 1300ms linear both;
  }
  .flourish {
    transition: none;
  }
}
```

### system-menu/glyphs.tsx

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

/*
 * The button glyphs of the callouts, and the console they point at, as small SVGs. Glyphs use one
 * light fill (--glyph) and one dark ink (--glyph-ink), like the other game pieces of the lab:
 *
 * - Face buttons (A, B, X, Y, +, −): a disc with the letter upright and bold.
 * - Shoulders (L, R) and triggers (ZL, ZR): a key with its outer top corner cut, or sloping.
 * - D-pad: a cross. Sticks: a cap seen slightly from above, with its letter.
 *
 * The console is a generic handheld drawn for this component: one body with two rounded grips, a
 * screen in a grey bezel, a d-pad over a stick on the left, four face buttons over a stick on the
 * right, small − and + keys, and the shoulder keys peeking over its top edge. Under it, the same
 * console seen from its top edge, where the shoulder keys and triggers are. Its local box is
 * 300 × 260 (front view 0–150, top edge 210–256); TARGETS gives the centre and reach of every
 * control, for the callouts' leaders.
 */

export type ControlGlyph =
  'A' | 'B' | 'X' | 'Y' | '+' | '-' | 'L' | 'R' | 'ZL' | 'ZR' | 'dpad' | 'ls' | 'rs';

/** The parts of the console a callout can point at. */
export type ConsoleTarget =
  'ls' | 'rs' | 'dpad' | 'face' | 'l' | 'zl' | 'r' | 'zr' | 'minus' | 'plus';

/** Each target's centre in the console's 300 × 260 box, its reach and the shape of its ring. */
export const TARGETS: Record<
  ConsoleTarget,
  { x: number; y: number; r: number; ring: 'circle' | 'diamond' | 'key' }
> = {
  ls: { x: 42, y: 101, r: 14, ring: 'circle' },
  rs: { x: 258, y: 101, r: 14, ring: 'circle' },
  dpad: { x: 40, y: 58, r: 15, ring: 'circle' },
  face: { x: 260, y: 58, r: 21, ring: 'diamond' },
  l: { x: 34, y: 237, r: 31, ring: 'key' },
  zl: { x: 34, y: 221, r: 28, ring: 'key' },
  r: { x: 266, y: 237, r: 31, ring: 'key' },
  zr: { x: 266, y: 221, r: 28, ring: 'key' },
  minus: { x: 64, y: 33, r: 6, ring: 'circle' },
  plus: { x: 236, y: 33, r: 6, ring: 'circle' },
};

/** What a screen reader says for each glyph. */
export function glyphName(glyph: ControlGlyph) {
  if (glyph === '+') return 'plus button';
  if (glyph === '-') return 'minus button';
  if (glyph === 'dpad') return 'd-pad';
  if (glyph === 'ls') return 'left stick';
  if (glyph === 'rs') return 'right stick';
  return `${glyph} button`;
}

const LETTER = { fontWeight: 600, fontStyle: 'normal' } as const;

export function Glyph({ glyph }: { glyph: ControlGlyph }) {
  if (glyph === 'L' || glyph === 'R' || glyph === 'ZL' || glyph === 'ZR') {
    const trigger = glyph.startsWith('Z');
    const right = glyph.endsWith('R');
    const shape = trigger
      ? 'M2 8.2 L32 1.3 L32 27 L2 27 Z'
      : 'M8.6 1.3 L34.6 1.3 L34.6 26.6 L1.4 26.6 Z';
    const width = trigger ? 34 : 36;
    return (
      <svg viewBox={`0 0 ${width} 28.5`} data-glyph="key" aria-hidden="true">
        <path
          d={shape}
          fill="var(--glyph)"
          stroke="var(--glyph)"
          strokeWidth="2.4"
          strokeLinejoin="round"
          transform={right ? `translate(${width} 0) scale(-1 1)` : undefined}
        />
        <text
          x={trigger ? 17 : right ? 16.5 : 19.5}
          y={trigger ? 21.8 : 20.4}
          textAnchor="middle"
          fontSize={trigger ? 16.5 : 17}
          fill="var(--glyph-ink)"
          style={LETTER}
        >
          {glyph}
        </text>
      </svg>
    );
  }
  if (glyph === 'dpad')
    return (
      <svg viewBox="0 0 32 32" data-glyph="face" aria-hidden="true">
        <path
          d="M10.4 1.5h11.2v8.9h8.9v11.2h-8.9v8.9H10.4v-8.9H1.5V10.4h8.9z"
          fill="var(--glyph)"
          stroke="var(--glyph)"
          strokeWidth="2"
          strokeLinejoin="round"
        />
        <circle cx="16" cy="16" r="3.2" fill="var(--glyph-ink)" />
      </svg>
    );
  if (glyph === 'ls' || glyph === 'rs')
    return (
      <svg viewBox="0 0 36 34" data-glyph="stick" aria-hidden="true">
        <ellipse cx="17.6" cy="31.2" rx="6" ry="2.6" fill="var(--glyph)" />
        <path d="M12 26h11.4l-1 4.6h-9.4z" fill="var(--glyph)" />
        <ellipse cx="18" cy="16.8" rx="16.8" ry="11.6" fill="var(--glyph)" />
        <ellipse cx="18" cy="13.2" rx="18" ry="12.6" fill="var(--glyph)" />
        <ellipse
          cx="18"
          cy="13.6"
          rx="15.2"
          ry="10.6"
          fill="none"
          stroke="var(--glyph-ink)"
          strokeWidth="1.5"
        />
        <text
          x="18"
          y="19.6"
          textAnchor="middle"
          fontSize="16"
          fill="var(--glyph-ink)"
          style={LETTER}
        >
          {glyph === 'ls' ? 'L' : 'R'}
        </text>
      </svg>
    );
  return (
    <svg viewBox="0 0 32 32" data-glyph="face" aria-hidden="true">
      <circle cx="16" cy="16" r="16" fill="var(--glyph)" />
      {glyph === '+' || glyph === '-' ? (
        <path
          d={glyph === '+' ? 'M16 8.5v15M8.5 16h15' : 'M8.5 16h15'}
          stroke="var(--glyph-ink)"
          strokeWidth="3.4"
        />
      ) : (
        <text
          x="16"
          y="22.8"
          textAnchor="middle"
          fontSize="19"
          fill="var(--glyph-ink)"
          style={LETTER}
        >
          {glyph}
        </text>
      )}
    </svg>
  );
}

/** A stick on the console: a grey well, the cap and its grip ring. */
function Stick({ x, y }: { x: number; y: number }) {
  return (
    <g>
      <circle cx={x} cy={y} r="14.5" fill="var(--console-edge)" />
      <circle cx={x} cy={y} r="12.6" fill="var(--console-control)" />
      <circle
        cx={x}
        cy={y}
        r="8.4"
        fill="none"
        stroke="var(--console-control-dark)"
        strokeWidth="1.2"
      />
    </g>
  );
}

/** The console, in its own 300 × 260 box (drawn into a <g> of the diagram). */
export function Console() {
  const faces = [
    [260, 47.5],
    [249.5, 58],
    [270.5, 58],
    [260, 68.5],
  ];
  return (
    <g data-part="console">
      {/* Triggers, then shoulder keys, behind the body's top edge. */}
      {[0, 1].map((side) => (
        <g key={side} transform={side ? 'translate(300 0) scale(-1 1)' : undefined}>
          <rect x="36" y="2" width="40" height="18" rx="6" fill="var(--console-key-back)" />
          <rect x="26" y="10" width="60" height="18" rx="7" fill="var(--console-key)" />
        </g>
      ))}
      <path
        d="M40 20H260C281 20 292.5 32 294 52L298 112C300 134 286 150 264 150C248 150 238 143 226 137H74C62 143 52 150 36 150C14 150 0 134 2 112L6 52C7.5 32 19 20 40 20Z"
        fill="var(--console-body)"
        stroke="var(--console-edge)"
        strokeWidth="1.6"
      />
      {/* The grips' seams, a quiet line where each hand rests. */}
      <path
        d="M70 128C60 138 50 144 38 144M230 128C240 138 250 144 262 144"
        fill="none"
        stroke="var(--console-edge)"
        strokeWidth="1"
        opacity="0.7"
      />
      <rect x="72" y="27" width="156" height="102" rx="8" fill="var(--console-bezel)" />
      <rect x="80" y="35" width="140" height="86" rx="3" fill="var(--console-screen)" />
      <path d="M80 35H170L120 121H80Z" fill="#fff" opacity="0.035" />
      {/* Left: − key, d-pad, stick, speaker holes. */}
      <rect x="59" y="31.5" width="10" height="3.2" rx="1.6" fill="var(--console-control)" />
      <path
        d="M35.6 45.6h8.8v8h8v8.8h-8v8h-8.8v-8h-8v-8.8h8z"
        fill="var(--console-control)"
        stroke="var(--console-control)"
        strokeWidth="1.6"
        strokeLinejoin="round"
      />
      <circle cx="40" cy="58" r="2.4" fill="var(--console-control-dark)" />
      <Stick x={42} y={101} />
      {[0, 1, 2].map((i) => (
        <circle key={i} cx={22 + i * 5} cy="128" r="1.3" fill="var(--console-edge)" />
      ))}
      {/* Right: + key, the four face buttons, stick, a round home key. */}
      <path
        d="M231 33h10M236 28v10"
        stroke="var(--console-control)"
        strokeWidth="3.2"
        strokeLinecap="round"
      />
      {faces.map(([cx, cy]) => (
        <circle key={`${cx}-${cy}`} cx={cx} cy={cy} r="5.8" fill="var(--console-control)" />
      ))}
      <Stick x={258} y={101} />
      <circle cx="278" cy="128" r="4" fill="var(--console-control)" />
      <circle cx="278" cy="128" r="1.6" fill="var(--console-control-dark)" />
      <TopEdge />
    </g>
  );
}

/** The console seen from its top edge: triggers over shoulder keys at each end, a slab with a
    power key, vents and a card slot, and the screen's dark edge below. */
function TopEdge() {
  return (
    <g transform="translate(0 210)">
      <path
        d="M12 21H288C293 21 296 24 296 29V37C296 42 292 46 287 46H13C8 46 4 42 4 37V29C4 24 7 21 12 21Z"
        fill="var(--console-body)"
        stroke="var(--console-edge)"
        strokeWidth="1.2"
      />
      <path
        d="M12 21H288C293 21 296 24 296 29V32H4V29C4 24 7 21 12 21Z"
        fill="#fff"
        opacity="0.4"
      />
      <path d="M78 36H222L214 44H86Z" fill="var(--console-screen)" />
      {[0, 1].map((side) => (
        <g key={side} transform={side ? 'translate(300 0) scale(-1 1)' : undefined}>
          <path d="M8 22C8 9 15 3 29 3H41C54 3 60 10 60 20V24H8Z" fill="var(--console-trigger)" />
          <rect
            x="5"
            y="18"
            width="58"
            height="10"
            rx="4"
            fill="var(--console-key)"
            stroke="var(--console-edge)"
            strokeWidth="1.2"
          />
          <path d="M66 22V44" stroke="var(--console-edge)" strokeWidth="1.2" />
        </g>
      ))}
      <circle cx="88" cy="27" r="2.6" fill="var(--console-control)" />
      <rect x="94" y="24.5" width="20" height="5" rx="2.5" fill="var(--console-control)" />
      {[0, 1, 2, 3, 4].map((i) => (
        <rect
          key={i}
          x={156 + i * 9}
          y="24.5"
          width="7"
          height="5"
          rx="1.5"
          fill="var(--console-control)"
        />
      ))}
      <rect
        x="206"
        y="23"
        width="22"
        height="8"
        rx="2"
        fill="var(--console-body)"
        stroke="var(--console-edge)"
      />
    </g>
  );
}

/** A leaf-shaped flourish beside the controls page's title, pointing at it. */
export function Flourish() {
  return (
    <svg viewBox="0 0 24 12" aria-hidden="true">
      <path
        d="M1 6.4C5 3 10.5 1.6 17 3.2L13.6 5.4L23 6L13.6 6.8L16.6 8.9C10.4 10.3 5.2 9.4 1 6.4Z"
        fill="currentColor"
      />
    </svg>
  );
}
```

### system-menu/use-autoplay.ts

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

'use client';

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

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

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

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

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