# Game modal

> A game’s modals: a choice stack with a gliding bright cursor, a message with an advance chevron, an item-get card over a golden burst and a satchel list.

The moments a game stops and waits for a button. Dark plates with ornamental corner brackets carry an italic title and subtitle; a choice modal stacks its options on a darker panel, with a bright cream frame for a cursor; a message waits with a bobbing chevron at its foot; the item-get card pops the item over a radiant golden burst with its name, a value, a perk and a description; the satchel lists items in black cells with Close (B), and a small Title / Description panel explains the one under the cursor. Each opens with a rise, its brackets sliding in, and closes in a quick sink, with the focus kept inside. Use the arrows or W/S, A or Enter to pick, B or Escape to go back, + for the inventory.

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

Category: Games · Dialogs.

## Anatomy

1. **Stage**: A transparent stage 28.5 em tall with the modal centred in it; its type size (the em) is set from its width, 16 px at full size, so the modal scales as a whole.
2. **Plate**: The dark plate of the choice and message modals, #33322e, with a 6 px radius; only its edge and shadow follow the page’s light or dark.
3. **Corner bracket**: One of four ornamental brackets 8 px inside the corners: one SVG turned for each corner, sliding in from outside as the modal opens.
4. **Title**: The question or the headline, italic and bold; it names the dialog. The italic subtitle under it is smaller and softer.
5. **Option stack**: A darker panel with a double edge holding the options as a column, 13 px apart; a group named by the title.
6. **Option**: A real button on a near-black slot; the one under the cursor drops its slot and shows through the frame. A roving tabindex keeps one in the tab order.
7. **Cursor frame**: One cream frame behind the options with a glow and four corner triangles. It is moved by the cursor’s index, so it glides with an overshoot and nothing is measured.
8. **Kind chips**: Uncontrolled, a row of chips that opens each kind of modal; inert and faded while one is open.

## When to use

- A game pausing to ask one short question with a few answers (rest, buy, travel, overwrite), picked with a pad, the keys or a click.
- A line the player must acknowledge before play goes on: a result, a discovery, a story beat, with a chevron that says “press to continue”.
- The reward moment, when the player picks something up: the item’s art, its name, one value, one perk and a line of lore.
- A short inventory in a game’s frame (a satchel, a pouch), with the focused item named and explained beside the list.
- A chain of moments that lead into each other (choose, then read, then receive), each opening as the last one closes.

## When not to use

- A confirmation in a product or a web app. Use the platform’s alert dialog, with its plain Cancel and Confirm buttons.
- Dialogue with a speaker, a portrait and lines typed out. Use a dialog box.
- A notice that should not stop play, such as a quest update or a pickup in passing. Use a toast or a HUD banner.
- A full inventory with tabs, a grid and stats compared. Use an item grid inside a menu screen.
- A long list of options, or options that need a description each. Use a choice list or a settings screen.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/game-modal.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 { useState } from 'react';
import { GameModal, type GameModalKind } from './game-modal';

export function CampMenu() {
  const [open, setOpen] = useState<GameModalKind | null>(null);
  return (
    <>
      <button onClick={() => setOpen('choice')}>Rest</button>
      <GameModal
        open={open}
        onOpenChange={setOpen}
        choice={{
          title: 'Rest by the fire?',
          subtitle: 'Time will pass while you rest',
          options: [
            { label: 'Until morning', next: 'message' },
            { label: 'Until night', next: 'message' },
            { label: 'Cancel', cancel: true },
          ],
        }}
        message={{ title: 'Rested by the fire', subtitle: 'Your strength is restored' }}
        onChoose={(index, option) => console.log(index, option.label)}
      />
    </>
  );
}
```

## States

- **Closed**: No modal: the Rest prompt (A) opens the choice, the chips open any kind.
- **Choice**: The question opened, the cursor on the first option.
- **Cursor moved**: The frame glided down to Until night (`defaultCursor={2}`); B would jump to Cancel.
- **Two options**: A yes-or-cancel choice: the stack and the plate shrink to fit.
- **Message**: Title, subtitle and a line, with the chevron bobbing at the foot until A.
- **Item get**: The reward card over its turning burst, with the + Inventory hint in the corner.
- **Item, no hint**: A guard value, a climb perk and no footer: all four brackets stay.
- **Satchel**: The first item framed, named and explained in the panel beside the list.
- **Satchel, browsed**: The cursor on the fourth item; the panel follows it.
- **Dark scheme**: `colorScheme="dark"`: a faint light edge and a deeper shadow; the plates keep their colours.

## How it works

- Four kinds share one frame language. A plate is #33322e with a 6 px radius and four 19 px corner brackets 8 px in: a chamfered L with a solid triangle in its corner and a pinhole in the triangle, drawn once as an SVG and turned for each corner. The item card is lighter grey with bright brackets; the satchel is near black with a double edge.
- The cursor is one element: a 1 px cream line with an olive light inside, a glow outside and a small solid triangle in each corner (four gradients). It sits behind the options and is translated by the cursor index times the option pitch, so it glides from option to option with a small overshoot without measuring anything; its glow breathes every 1.7 s.
- Opening, the dialog rises 0.9 em from 94% with an overshoot in 320 ms; the brackets slide in from outside their corners 70 ms later; title, subtitle and body follow 50 ms apart, the options slide in from the left 45 ms apart and the frame settles on the first one last. Closing sinks and fades in 170 ms; a modal that leads to another (choice to message, item to the satchel) closes, then the next one opens.
- A press remounts the pressed part, which replays its keyframes: the frame dips to 96.5% and flashes, the chevron and the button glyphs dip, the card flashes faintly; the action follows 190 ms later, so the press is seen first.
- The item-get card: the burst is a repeating conic gradient of soft rays masked by a radial fade, which blooms from 20% with an overshoot and then turns once every 24 s; the item glyph drops in from half its size and settles; the name, the rule, the value box and the perk chip, and the description come in after it.
- Everything is in em of the stage’s type (16 px at full size, the option label), and the stage sets its font size from its own width with a container query, so a modal scales as a whole down to 320 px; on a narrow stage the Title / Description panel moves under the satchel.
- Uncontrolled, it keeps its own modal: a Rest prompt opens the choice, and a row of chips opens each kind. A demo plays a short evening at camp (rest, pick an hour, read the message, get the item, look through the satchel, close) while on screen and untouched; any input hands it to the reader. Given open, it shows that modal and asks onOpenChange to change it.

## API reference

- **open** (`'choice' | 'message' | 'item' | 'list' | null`): The modal shown, controlled: null shows none. The demo is off, and every move to another modal (an option’s next, Close, B) asks onOpenChange.
- **defaultOpen** (`'choice' | 'message' | 'item' | 'list' | null`, default `null`): Uncontrolled, the modal shown at first.
- **defaultCursor** (`number`, default `0`): The option or satchel item under the cursor when the first modal is shown; every modal opened later starts on its first.
- **onOpenChange** (`(kind: GameModalKind | null) => void`): Called when a modal opens, leads to another or closes.
- **choice** (`ChoiceContent`, default `Rest by the fire?`): { title, subtitle?, options }. An option is { label, next?, cancel? }: next is the modal it leads to (absent closes), and B picks the option marked cancel.
- **message** (`MessageContent`, default `Rested by the fire`): { title, subtitle?, body?, next? }: A or a click goes to next, or closes.
- **item** (`ItemContent`, default `Ember Blade`): { name, glyph, value?, valueIcon?, perk?, perkIcon?, description, footer?, footerNext?, next? }. glyph is 'blade', 'emberfruit', 'capshroom', 'frostherb', 'riverfish' or 'amber'; valueIcon 'power' or 'guard'; perkIcon 'cold', 'climb' or 'speed'. + presses the footer hint and goes to footerNext.
- **list** (`ListContent`, default `Satchel, five items`): { title, items }: each item is { name, description, glyph }.
- **onChoose** (`(index: number, option: ModalOption) => void`): An option of the choice modal was picked (Cancel included).
- **onSelectItem** (`(index: number, item: ListItem) => void`): A satchel item was picked with A, Enter or a click.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a short evening at camp plays through every modal. Uncontrolled only.
- **colorScheme** (`'light' | 'dark' | 'system'`): Forces light or dark for the plates’ edge and shadow, the prompt and the chips; by default it follows the page. The plates keep their colours.
- **className** (`string`): Added to the root.

## Accessibility

- Each modal is a role="dialog" with aria-modal, named by its title. Opened by the reader, it takes the focus (the option under the cursor, or its main control); Tab and Shift+Tab stay inside it; closing gives the focus back to what opened it. The prompt and the chips behind it are inert while it is open.
- Options and satchel items are real buttons with a roving tabindex: the focus follows the cursor, and Up/Down (or W/S), Home and End move it. A or Enter picks, B, Escape or Backspace goes back (in a choice it picks Cancel), + presses the inventory hint; the hints say their keys with aria-keyshortcuts.
- The message’s advance and the item card are buttons named Continue and OK; the satchel’s focused item is described by the Title / Description panel (aria-describedby).
- A polite live region reads each modal as it opens (its title and subtitle, “Got Ember Blade.”) and the option or item picked.
- Every control shows a :focus-visible ring; the brackets, burst, glyphs and frame are aria-hidden.
- Reduced motion: no rise, slides, glides, pops, bloom, turning burst, bob or breathing; modals and their parts fade. The demo never plays, and the loops pause off screen.

## Source

### game-modal/game-modal.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 {
  Bracket,
  Chevron,
  Face,
  ItemGlyph,
  Perk,
  Stat,
  type ItemGlyphName,
  type PerkIcon,
  type StatIcon,
} from './glyphs';
import { useAutoplay } from './use-autoplay';
import styles from './game-modal.module.css';

/*
 * Game modal: the moments a game stops and asks, tells or shows something, and waits for a button.
 * Four kinds share one frame language: dark plates with ornamental corner brackets, italic labels,
 * and a bright cream frame that is the cursor.
 *
 * - choice: a title and subtitle over a stack of options on a darker panel. The bright frame is one
 *   element that glides from option to option (it is translated by the cursor index, so nothing is
 *   measured); A picks, B jumps to the Cancel option and picks it.
 * - message: a title and subtitle on an empty plate, with the advance chevron bobbing at its foot.
 * - item: the reward card. The item's glyph pops in over a golden burst that blooms and then turns
 *   slowly; the name, a value box, a perk chip and the description follow it in. A closes, + opens
 *   the inventory (the list).
 * - list: a black satchel of item cells with Close (B). The frame jumps from cell to cell, and the
 *   small Title / Description panel beside it names and explains the focused item.
 *
 * Opening plays a rise with a slight overshoot, the brackets slide in from outside, and the labels
 * and options follow in a stagger; closing sinks and fades in 170 ms. A modal moving to another
 * (choice to message, item to list) closes, then the next one opens.
 *
 * The modal is a real dialog: opened from the keyboard or a click, it takes the focus (the option
 * under the cursor, or its main control), keeps Tab inside, and gives the focus back when it
 * closes. What is behind it is inert. A demo opened by the autoplay never moves the focus.
 */

export type GameModalKind = 'choice' | 'message' | 'item' | 'list';

export type ModalOption = {
  label: string;
  /** The modal to open after this option is picked; null or absent closes. */
  next?: GameModalKind | null;
  /** The option B picks. */
  cancel?: boolean;
};
export type ChoiceContent = { title: string; subtitle?: string; options: ModalOption[] };
export type MessageContent = {
  title: string;
  subtitle?: string;
  body?: string;
  next?: GameModalKind | null;
};
export type ItemContent = {
  name: string;
  glyph: ItemGlyphName;
  value?: number | string;
  valueIcon?: StatIcon;
  perk?: string;
  perkIcon?: PerkIcon;
  description: string;
  /** The footer hint, pressed with +. */
  footer?: string;
  /** Where + goes. */
  footerNext?: GameModalKind | null;
  /** Where A goes; absent closes. */
  next?: GameModalKind | null;
};
export type ListItem = { name: string; description: string; glyph: ItemGlyphName };
export type ListContent = { title: string; items: ListItem[] };

export type GameModalProps = {
  /** The modal shown, controlled (null: none). */
  open?: GameModalKind | null;
  /** Uncontrolled, the modal shown at first. */
  defaultOpen?: GameModalKind | null;
  /** The cursor's option or satchel item when the first modal is shown (later modals start at 0). */
  defaultCursor?: number;
  onOpenChange?: (kind: GameModalKind | null) => void;
  choice?: ChoiceContent;
  message?: MessageContent;
  item?: ItemContent;
  list?: ListContent;
  /** An option of the choice modal was picked. */
  onChoose?: (index: number, option: ModalOption) => void;
  /** A satchel item was picked with A. */
  onSelectItem?: (index: number, item: ListItem) => void;
  autoplay?: boolean;
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
};

const CHOICE: ChoiceContent = {
  title: 'Rest by the fire?',
  subtitle: 'Time will pass while you rest',
  options: [
    { label: 'Until morning', next: 'message' },
    { label: 'Until noon', next: 'message' },
    { label: 'Until night', next: 'message' },
    { label: 'Cancel', cancel: true },
  ],
};
const MESSAGE: MessageContent = {
  title: 'Rested by the fire',
  subtitle: 'Your strength is restored',
  body: 'Something glints in the ashes…',
  next: 'item',
};
const ITEM: ItemContent = {
  name: 'Ember Blade',
  glyph: 'blade',
  value: 28,
  valueIcon: 'power',
  perk: 'Cold Guard Up',
  perkIcon: 'cold',
  description:
    'A long blade forged by a mountain spring. It stays warm to the touch and keeps its bearer safe from the cold, but its edge dulls quickly.',
  footer: 'Inventory',
  footerNext: 'list',
};
const LIST: ListContent = {
  title: 'Satchel',
  items: [
    { name: 'Emberfruit', glyph: 'emberfruit', description: 'Sweet and warm. Restores a heart.' },
    { name: 'Capshroom', glyph: 'capshroom', description: 'Earthy. Better cooked than raw.' },
    { name: 'Frost Herb', glyph: 'frostherb', description: 'Cools the body in the heat.' },
    { name: 'River Fish', glyph: 'riverfish', description: 'Fresh from the shallows.' },
    { name: 'Amber Stone', glyph: 'amber', description: 'Traders pay well for it.' },
  ],
};

const CLOSE_MS = 170;
const PRESS_MS = 190;

type Action = 'up' | 'down' | 'confirm' | 'cancel' | 'plus' | 'open' | 'close';
// A short evening at camp: rest, read the message, get the item, look through the satchel.
const DEMO: [number, Action][] = [
  [1300, 'open'],
  [1150, 'down'],
  [620, 'down'],
  [620, 'up'],
  [900, 'confirm'],
  [2500, 'confirm'],
  [3300, 'plus'],
  [1400, 'down'],
  [900, 'down'],
  [900, 'down'],
  [1500, 'cancel'],
];

export function GameModal({
  open,
  defaultOpen = null,
  defaultCursor = 0,
  onOpenChange,
  choice = CHOICE,
  message = MESSAGE,
  item = ITEM,
  list = LIST,
  onChoose,
  onSelectItem,
  autoplay = true,
  colorScheme,
  className,
}: GameModalProps) {
  const { ref, take, playing, visible, reduced } = useAutoplay<HTMLDivElement>();
  const controlled = open !== undefined;
  const [own, setOwn] = useState<GameModalKind | null>(defaultOpen);
  const want = controlled ? open : own;
  // What is drawn lags what is wanted by the closing animation.
  const [shown, setShown] = useState<GameModalKind | null>(want);
  const [phase, setPhase] = useState<'in' | 'out'>('in');
  const [serial, setSerial] = useState(0);
  const [cursor, setCursor] = useState(defaultCursor);
  const [press, setPress] = useState<{ what: string; n: number }>({ what: '', n: 0 });
  const [said, setSaid] = useState('');
  const byUser = useRef(false);
  const returnTo = useRef<HTMLElement | null>(null);
  const dialogRef = useRef<HTMLDivElement>(null);
  const promptRef = useRef<HTMLButtonElement>(null);
  const pressTimer = useRef(0);
  const ids = useId();
  const titleId = `${ids}-title`;
  const descId = `${ids}-desc`;

  // Wanted and shown differ: close what is shown, then open what is wanted.
  useEffect(() => {
    if (want === shown && phase === 'in') return;
    if (shown && phase === 'in') {
      setPhase('out');
      return;
    }
    const swap = () => {
      setShown(want);
      setPhase('in');
      setCursor(0);
      setPress({ what: '', n: 0 });
      if (want) setSerial((n) => n + 1);
    };
    if (!shown) {
      swap();
      return;
    }
    const timer = window.setTimeout(swap, reduced ? 60 : CLOSE_MS);
    return () => window.clearTimeout(timer);
  }, [want, shown, phase, reduced]);

  // A modal opened by the reader takes the focus; closed by the reader, it gives it back.
  useEffect(() => {
    if (!byUser.current) return;
    if (shown) {
      const target = dialogRef.current?.querySelector<HTMLElement>('[data-primary]');
      target?.focus({ preventScroll: true });
    } else {
      const back = returnTo.current?.isConnected ? returnTo.current : promptRef.current;
      back?.focus({ preventScroll: true });
      returnTo.current = null;
    }
  }, [shown, serial]);

  // Roving focus: the focus follows the cursor while it is inside the modal.
  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog || !dialog.contains(document.activeElement)) return;
    dialog.querySelector<HTMLElement>('[data-primary]')?.focus({ preventScroll: true });
  }, [cursor]);

  // A satchel longer than its window scrolls to keep the cursor in view.
  useEffect(() => {
    if (shown !== 'list') return;
    dialogRef.current
      ?.querySelector<HTMLElement>('[data-primary]')
      ?.scrollIntoView({ block: 'nearest' });
  }, [cursor, shown]);

  useEffect(() => () => window.clearTimeout(pressTimer.current), []);

  // Announce each modal as it opens.
  useEffect(() => {
    if (!shown) return;
    const text =
      shown === 'choice'
        ? `${choice.title}${choice.subtitle ? `. ${choice.subtitle}` : ''}`
        : shown === 'message'
          ? `${message.title}${message.subtitle ? `. ${message.subtitle}` : ''}`
          : shown === 'item'
            ? `Got ${item.name}.`
            : list.title;
    setSaid(text);
  }, [shown, serial, choice, message, item, list]);

  const request = (kind: GameModalKind | null, user: boolean) => {
    byUser.current = user;
    if (user && kind && !shown) {
      const active = document.activeElement;
      returnTo.current =
        active instanceof HTMLElement && ref.current?.contains(active) ? active : null;
    }
    if (!controlled) setOwn(kind);
    onOpenChange?.(kind);
  };

  const pressThen = (what: string, then: () => void) => {
    window.clearTimeout(pressTimer.current);
    setPress((p) => ({ what, n: p.n + 1 }));
    pressTimer.current = window.setTimeout(then, reduced ? 0 : PRESS_MS);
  };

  const count =
    shown === 'choice' ? choice.options.length : shown === 'list' ? list.items.length : 0;
  const ready = !!shown && phase === 'in' && want === shown;

  const move = (delta: number) => {
    if (!ready || !count) return;
    setCursor((c) => Math.min(count - 1, Math.max(0, c + delta)));
  };

  const confirm = (user: boolean, at = cursor) => {
    if (!ready) return;
    if (shown === 'choice') {
      const option = choice.options[at];
      if (!option) return;
      setCursor(at);
      setSaid(option.label);
      pressThen('option', () => {
        onChoose?.(at, option);
        request(option.cancel ? null : (option.next ?? null), user);
      });
    } else if (shown === 'message') {
      pressThen('advance', () => request(message.next ?? null, user));
    } else if (shown === 'item') {
      pressThen('ok', () => request(item.next ?? null, user));
    } else if (shown === 'list') {
      const entry = list.items[at];
      if (!entry) return;
      setCursor(at);
      setSaid(entry.name);
      pressThen('cell', () => onSelectItem?.(at, entry));
    }
  };

  const cancel = (user: boolean) => {
    if (!ready) return;
    if (shown === 'choice') {
      const at = choice.options.findIndex((option) => option.cancel);
      if (at >= 0) {
        confirm(user, at);
        return;
      }
      request(null, user);
    } else if (shown === 'list') {
      pressThen('close', () => request(null, user));
    } else request(null, user);
  };

  const plus = (user: boolean) => {
    if (!ready || shown !== 'item' || !item.footer) return;
    pressThen('footer', () => request(item.footerNext ?? null, user));
  };

  const openFirst = (user: boolean) => {
    if (shown || want) return;
    pressThen('prompt', () => request('choice', user));
  };

  const act = useEffectEvent((action: Action) => {
    if (action === 'open') openFirst(false);
    else if (action === 'up') move(-1);
    else if (action === 'down') move(1);
    else if (action === 'confirm') confirm(false);
    else if (action === 'cancel') cancel(false);
    else if (action === 'plus') plus(false);
    else if (want) request(null, false);
  });

  // The demo: a short evening at camp, while on screen and untouched. Uncontrolled only.
  const demo = playing && autoplay && !controlled;
  useEffect(() => {
    if (!demo) return;
    let step = 0;
    let timer = 0;
    const next = () => {
      const [delay, action] = DEMO[step % DEMO.length];
      timer = window.setTimeout(() => {
        act(action);
        step++;
        next();
      }, delay);
    };
    // Whatever was left open closes first, so the evening starts from the camp.
    act('close');
    next();
    return () => window.clearTimeout(timer);
  }, [demo]);

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    if (event.metaKey || event.ctrlKey || event.altKey) return;
    take();
    const key = event.key;
    if (!shown) {
      if ((key === 'a' || key === 'A') && !(event.target instanceof HTMLButtonElement)) {
        event.preventDefault();
        openFirst(true);
      }
      return;
    }
    if (key === 'Tab') {
      const dialog = dialogRef.current;
      if (!dialog) return;
      const stops = [...dialog.querySelectorAll<HTMLElement>('button:not([tabindex="-1"])')];
      event.preventDefault();
      if (!stops.length) return;
      const at = stops.indexOf(document.activeElement as HTMLElement);
      const to = event.shiftKey
        ? at <= 0
          ? stops.length - 1
          : at - 1
        : at < 0 || at === stops.length - 1
          ? 0
          : at + 1;
      stops[to].focus();
      return;
    }
    const handled = () => event.preventDefault();
    if (key === 'ArrowUp' || key === 'w' || key === 'W') (handled(), move(-1));
    else if (key === 'ArrowDown' || key === 's' || key === 'S') (handled(), move(1));
    else if (key === 'Home') (handled(), move(-count));
    else if (key === 'End') (handled(), move(count));
    else if (key === 'a' || key === 'A') (handled(), confirm(true));
    else if (key === 'Escape' || key === 'b' || key === 'B' || key === 'Backspace')
      (handled(), cancel(true));
    else if (key === '+' || key === '=') (handled(), plus(true));
  };

  const pressed = (what: string) => (press.what === what ? press.n : 0);

  let body: ReactNode = null;
  if (shown === 'choice') {
    body = (
      <div className={styles.plate} data-part="plate">
        <Corners />
        <h2 className={styles.title} id={titleId} data-part="title">
          {choice.title}
        </h2>
        {choice.subtitle && (
          <p className={styles.subtitle} data-part="subtitle">
            {choice.subtitle}
          </p>
        )}
        <div className={styles.stack} role="group" aria-labelledby={titleId} data-part="options">
          <span
            key={pressed('option')}
            className={styles.frame}
            style={{ '--i': cursor } as CSSProperties}
            data-pressed={pressed('option') > 0 || undefined}
            data-part="frame"
            aria-hidden="true"
          />
          {choice.options.map((option, index) => (
            <button
              key={option.label + index}
              type="button"
              className={styles.option}
              data-part="option"
              style={{ '--n': index } as CSSProperties}
              tabIndex={index === cursor ? 0 : -1}
              data-primary={index === cursor || undefined}
              data-on={index === cursor || undefined}
              aria-keyshortcuts={option.cancel ? 'B Escape' : undefined}
              onPointerEnter={(event) => {
                if (event.pointerType === 'mouse' && ready) setCursor(index);
              }}
              onClick={() => {
                take();
                confirm(true, index);
              }}
            >
              {option.label}
            </button>
          ))}
        </div>
      </div>
    );
  } else if (shown === 'message') {
    body = (
      <div className={styles.plate} data-message="" data-part="plate">
        <Corners />
        <h2 className={styles.title} id={titleId} data-part="title">
          {message.title}
        </h2>
        {message.subtitle && (
          <p className={styles.subtitle} data-part="subtitle">
            {message.subtitle}
          </p>
        )}
        {message.body && <p className={styles.body}>{message.body}</p>}
        <button
          type="button"
          className={styles.advance}
          data-primary=""
          aria-label="Continue"
          aria-keyshortcuts="A Enter"
          onClick={() => {
            take();
            confirm(true);
          }}
        >
          <span
            key={pressed('advance')}
            className={styles.chevronBox}
            data-pressed={pressed('advance') > 0 || undefined}
            data-part="chevron"
          >
            <Chevron className={styles.chevron} />
          </span>
        </button>
      </div>
    );
  } else if (shown === 'item') {
    body = (
      <div className={styles.card} data-part="card">
        <Corners bright footer={!!item.footer} />
        <div className={styles.art} aria-hidden="true" data-part="art">
          <span className={styles.burstBloom}>
            <span className={styles.burst} />
          </span>
          <ItemGlyph name={item.glyph} className={styles.itemGlyph} />
        </div>
        <div className={styles.info}>
          <h2 className={styles.itemName} id={titleId} data-part="name">
            {item.name}
          </h2>
          {(item.value !== undefined || item.perk) && (
            <div className={styles.stats} data-part="stats">
              {item.value !== undefined && (
                <>
                  <Stat icon={item.valueIcon ?? 'power'} className={styles.statIcon} />
                  <span className={styles.value} data-part="value">
                    <span className={styles.srOnly}>
                      {item.valueIcon === 'guard' ? 'Guard' : 'Power'}{' '}
                    </span>
                    {item.value}
                  </span>
                </>
              )}
              {item.perk && (
                <span className={styles.perk} data-part="perk">
                  <Perk icon={item.perkIcon ?? 'cold'} className={styles.perkIcon} />
                  {item.perk}
                </span>
              )}
            </div>
          )}
          <p className={styles.desc}>{item.description}</p>
        </div>
        <button
          key={pressed('ok')}
          type="button"
          className={styles.hit}
          data-primary=""
          data-pressed={pressed('ok') > 0 || undefined}
          aria-label="OK"
          aria-keyshortcuts="A Enter"
          onClick={() => {
            take();
            confirm(true);
          }}
        />
        {item.footer && (
          <button
            key={`f${pressed('footer')}`}
            type="button"
            className={styles.footer}
            data-pressed={pressed('footer') > 0 || undefined}
            aria-keyshortcuts="+"
            onClick={() => {
              take();
              plus(true);
            }}
            data-part="footer"
          >
            {item.footer}
            <Face button="+" className={styles.footerGlyph} />
          </button>
        )}
      </div>
    );
  } else if (shown === 'list') {
    const current = list.items[cursor];
    body = (
      <div className={styles.listWrap}>
        <div className={styles.satchel} data-part="list">
          <h2 className={styles.srOnly} id={titleId}>
            {list.title}
          </h2>
          <div className={styles.cells} role="group" aria-labelledby={titleId} data-part="cells">
            <span
              key={pressed('cell')}
              className={styles.cellFrame}
              style={{ '--i': cursor } as CSSProperties}
              data-pressed={pressed('cell') > 0 || undefined}
              data-part="frame"
              aria-hidden="true"
            />
            {list.items.map((entry, index) => (
              <button
                key={entry.name + index}
                type="button"
                className={styles.row}
                style={{ '--n': index } as CSSProperties}
                tabIndex={index === cursor ? 0 : -1}
                data-primary={index === cursor || undefined}
                aria-describedby={index === cursor ? descId : undefined}
                onPointerEnter={(event) => {
                  if (event.pointerType === 'mouse' && ready) setCursor(index);
                }}
                onClick={() => {
                  take();
                  confirm(true, index);
                }}
              >
                <span className={styles.cell} data-part="cell">
                  <ItemGlyph name={entry.glyph} className={styles.cellGlyph} />
                </span>
                <span className={styles.rowName}>{entry.name}</span>
              </button>
            ))}
          </div>
          <button
            key={`c${pressed('close')}`}
            type="button"
            className={styles.close}
            data-pressed={pressed('close') > 0 || undefined}
            aria-keyshortcuts="B Escape"
            onClick={() => {
              take();
              cancel(true);
            }}
            data-part="close"
          >
            Close
            <Face button="B" className={styles.closeGlyph} />
          </button>
        </div>
        {current && (
          <div className={styles.detail} data-part="detail">
            <span key={`t${cursor}`} className={styles.pill} data-part="pill">
              {current.name}
            </span>
            <span key={`s${cursor}`} className={styles.square} aria-hidden="true">
              <ItemGlyph name={current.glyph} className={styles.squareGlyph} />
            </span>
            <span key={`d${cursor}`} className={styles.pillWide} id={descId}>
              {current.description}
            </span>
          </div>
        )}
      </div>
    );
  }

  return (
    <div
      ref={ref}
      className={`${styles.root} ${className ?? ''}`}
      data-color-scheme={colorScheme}
      data-paused={!visible || undefined}
      onKeyDown={onKeyDown}
      onPointerDown={take}
    >
      <div className={styles.stage} data-part="stage">
        <div className={styles.idle} data-hidden={!!shown || !!want || undefined} inert={!!shown}>
          <button
            ref={promptRef}
            key={`p${pressed('prompt')}`}
            type="button"
            className={styles.prompt}
            data-pressed={pressed('prompt') > 0 || undefined}
            aria-haspopup="dialog"
            aria-keyshortcuts="A"
            onClick={() => {
              take();
              openFirst(true);
            }}
            data-part="prompt"
          >
            <Face button="A" className={styles.promptGlyph} />
            <span>Rest</span>
          </button>
        </div>
        {shown && (
          <div
            className={styles.layer}
            data-phase={phase}
            onClick={(event) => {
              if (event.target === event.currentTarget) {
                take();
                cancel(true);
              }
            }}
          >
            <div
              ref={dialogRef}
              key={serial}
              role="dialog"
              aria-modal="true"
              aria-labelledby={titleId}
              className={styles.dialog}
              data-kind={shown}
              data-phase={phase}
              inert={phase === 'out'}
            >
              {body}
            </div>
          </div>
        )}
      </div>
      <div
        className={styles.kinds}
        role="group"
        aria-label="Open a modal"
        inert={!!shown}
        data-part="kinds"
      >
        {(
          [
            ['choice', 'Choice'],
            ['message', 'Message'],
            ['item', 'Item get'],
            ['list', 'Satchel'],
          ] as const
        ).map(([kind, label]) => (
          <button
            key={kind}
            type="button"
            className={styles.kind}
            aria-haspopup="dialog"
            onClick={() => {
              take();
              request(kind, true);
            }}
          >
            {label}
          </button>
        ))}
      </div>
      <p className={styles.live} aria-live="polite">
        {said}
      </p>
    </div>
  );
}

function Corners({ bright, footer }: { bright?: boolean; footer?: boolean }) {
  return (
    <span
      className={styles.corners}
      data-bright={bright || undefined}
      data-footer={footer || undefined}
      aria-hidden="true"
    >
      {(['tl', 'tr', 'br', 'bl'] as const).map((corner) => (
        <span key={corner} className={styles.corner} data-corner={corner} data-part="bracket">
          <Bracket className={styles.bracket} />
        </span>
      ))}
    </span>
  );
}
```

### game-modal/game-modal.module.css

```css
/* Game modal (game-modal.tsx): the modals on their own, centred on a plain stage, with a row of
   chips under it to open each kind. Every size is in em of the stage's type: 1 em is 16 px at full
   size (the option label), so the whole modal scales as one on a narrow screen (a container query
   sets the stage's font size from its width).

   The plates keep their own dark colours on any page, as a game's modals do over any scene; only
   their edge, their shadow, the prompt's plate and the chips follow the colour scheme, through
   light-dark() tokens. No filter touches the art or the text: glows are box- and text-shadows.

   Motion: the dialog rises with an overshoot and sinks on close; the brackets slide in from outside;
   labels and options stagger in (--n); the cursor frame glides by a transition of its translate and
   breathes; presses replay keyframes by remounting their element. The item's burst blooms, then
   turns; the message's chevron bobs. Endless loops pause while the component is off screen. */

.root {
  /* The plates. */
  --plate: #33322e;
  --panel: #1a1917;
  --panel-dark: #0f0e0d;
  --panel-line: #5d5c57;
  --option: #0a0a09;
  --option-edge: #1f1e1b;
  --option-on: #121210;
  --frame: #bdb7a4;
  --frame-hot: #ece8dc;
  --frame-inner: rgb(112 104 72 / 0.95);
  --frame-glow: rgb(222 214 188 / 0.62);
  --text: #e4e1d6;
  --text-soft: #d6d3c8;
  --bracket: #74726b;
  --bracket-bright: #dedace;
  --card: #716f69;
  --card-rule: rgb(214 210 198 / 0.55);
  --box: #2d2c2a;
  --box-edge: #57554f;
  --perk: #74d9a1;
  --ray: rgb(246 232 172 / 0.42);
  --ray-core: rgb(240 226 168 / 0.55);
  --satchel: #141413;
  --satchel-edge: #4f4e4a;
  --satchel-line: #2f2f2d;
  --cell: #040404;
  --cell-edge: #22221f;
  --pill: #43413c;
  --glyph: #e6e3d7;
  --glyph-ink: #24221d;
  --chevron-fill: var(--plate);
  /* What follows the page: the plates' edge and shadow, the prompt's plate, the chips. */
  --edge: rgb(255 255 255 / 0);
  --shade: rgb(24 20 12 / 0.26);
  --prompt-plate: rgb(26 25 22 / 0.9);
  --chip: #f2f1ee;
  --chip-hover: #e8e7e3;
  --chip-text: #4a4944;
  --chip-edge: rgb(0 0 0 / 0.08);
  --focus: #2f6fdf;
  --focus-on-dark: #8db4ff;
  position: relative;
  container-type: inline-size;
  isolation: isolate;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 18px;
  width: 100%;
  padding: 24px 12px;
  box-sizing: border-box;
  color: var(--text);
  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(255 255 255 / 0), rgb(255 255 255 / 0.11));
    --shade: light-dark(rgb(24 20 12 / 0.26), rgb(0 0 0 / 0.55));
    --prompt-plate: light-dark(rgb(26 25 22 / 0.9), rgb(14 14 12 / 0.85));
    --chip: light-dark(#f2f1ee, #26262a);
    --chip-hover: light-dark(#e8e7e3, #303035);
    --chip-text: light-dark(#4a4944, #c9c8c3);
    --chip-edge: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.1));
    --focus: light-dark(#2f6fdf, #8db4ff);
  }
}
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}
.root :where(button) {
  font: inherit;
  color: inherit;
  -webkit-tap-highlight-color: transparent;
}

/* The stage: room for the tallest modal, the modal centred in it. */
.stage {
  position: relative;
  width: 100%;
  height: 28.5em;
  font-size: clamp(11px, calc((100cqi - 20px) / 28.4), 16px);
  line-height: 1.2;
}

/* ——— The camp prompt shown while no modal is open ——— */
.idle {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  transition:
    opacity 180ms ease,
    scale 220ms ease;
}
.idle[data-hidden] {
  opacity: 0;
  scale: 0.96;
}
.prompt {
  display: inline-flex;
  align-items: center;
  gap: 0.55em;
  height: 2.6em;
  padding: 0 1.1em 0 0.4em;
  border: 0;
  border-radius: 999px;
  background: var(--prompt-plate);
  box-shadow:
    0 0 0 1px var(--edge),
    0 0.3em 1em var(--shade);
  color: var(--text);
  font-size: 1.125em;
  font-style: italic;
  font-weight: 500;
  cursor: pointer;
}
.promptGlyph {
  width: 1.75em;
  height: 1.75em;
}
.prompt[data-pressed] .promptGlyph {
  animation: dip 260ms cubic-bezier(0.3, 1.5, 0.5, 1);
}
.prompt:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 3px;
}

/* ——— The layer and the dialog ——— */
.layer {
  position: absolute;
  inset: 0;
  z-index: 1;
  display: grid;
  /* A track that never grows past the stage, so a modal wider than a narrow stage shrinks into it. */
  grid-template-columns: minmax(0, 1fr);
  place-items: center;
}
.dialog {
  position: relative;
  max-width: 100%;
  animation: rise 320ms cubic-bezier(0.2, 0.9, 0.28, 1.12) both;
}
.dialog[data-phase='out'] {
  animation: sink 170ms cubic-bezier(0.5, 0, 0.9, 0.6) forwards;
  pointer-events: none;
}
.dialog:focus {
  outline: none;
}

/* Labels and options arrive after the plate, one after another. */
.title,
.subtitle,
.body,
.itemName,
.stats,
.desc,
.footer,
.close,
.pill,
.square,
.pillWide {
  animation: text-in 260ms cubic-bezier(0.2, 0.8, 0.3, 1) both;
}
.title,
.itemName {
  animation-delay: 90ms;
}
.subtitle,
.stats {
  animation-delay: 140ms;
}
.body,
.desc {
  animation-delay: 190ms;
}
.footer,
.close {
  animation-delay: 300ms;
}

/* ——— Plates (choice and message) ——— */
.plate {
  position: relative;
  box-sizing: border-box;
  width: 27.0625em;
  max-width: 100%;
  min-height: 13.1875em;
  padding: 1.4em 1.5em 1.75em;
  border-radius: 0.375em;
  background: var(--plate);
  box-shadow:
    0 0 0 1px var(--edge),
    0 0.7em 1.8em var(--shade);
  text-align: center;
  --bracket-hole: var(--plate);
}
.title {
  margin: 0;
  color: #ebe8dd;
  font-size: 1.5em;
  font-style: italic;
  font-weight: 700;
  line-height: 1.15;
  letter-spacing: -0.005em;
  text-shadow: 0 1px 0 rgb(0 0 0 / 0.35);
}
.subtitle {
  margin: 0.05em 0 0;
  color: var(--text-soft);
  font-size: 0.875em;
  font-style: italic;
  line-height: 1.2;
}
.body {
  margin: 2.6em 0 0;
  color: var(--text);
  font-size: 0.875em;
  font-style: italic;
  font-weight: 500;
}

/* The corner brackets: drawn for the top left, turned for the others, sliding in from outside. */
.corners {
  position: absolute;
  inset: 0;
  pointer-events: none;
  color: var(--bracket);
}
.corners[data-bright] {
  color: var(--bracket-bright);
}
/* The footer hint takes the bottom-right corner of the item card. */
.corners[data-footer] .corner[data-corner='br'] {
  display: none;
}
.corner {
  --dx: -0.45em;
  --dy: -0.45em;
  position: absolute;
  top: 0.5em;
  left: 0.5em;
  width: 1.1875em;
  height: 1.1875em;
  animation: bracket-in 340ms cubic-bezier(0.2, 0.9, 0.3, 1.2) 70ms both;
}
.corner[data-corner='tr'] {
  --dx: 0.45em;
  left: auto;
  right: 0.5em;
  rotate: 90deg;
}
.corner[data-corner='br'] {
  --dx: 0.45em;
  --dy: 0.45em;
  top: auto;
  left: auto;
  right: 0.5em;
  bottom: 0.5em;
  rotate: 180deg;
}
.corner[data-corner='bl'] {
  --dy: 0.45em;
  top: auto;
  bottom: 0.5em;
  rotate: 270deg;
}
.bracket {
  display: block;
  width: 100%;
  height: 100%;
}

/* ——— The option stack ——— */
.stack {
  position: relative;
  display: grid;
  gap: 0.8125em;
  box-sizing: border-box;
  width: 13.0625em;
  margin: 1.2em auto 0;
  padding: 0.875em;
  border: 1px solid var(--panel-dark);
  background: var(--panel);
  box-shadow: inset 0 0 0 1px var(--panel-line);
}
.option {
  position: relative;
  z-index: 1;
  height: 2.3125em;
  padding: 0 0.5em;
  border: 1px solid var(--option-edge);
  background: var(--option);
  color: var(--text);
  font-style: italic;
  font-weight: 500;
  white-space: nowrap;
  text-shadow: 0 1px 0 rgb(0 0 0 / 0.6);
  cursor: pointer;
  animation: option-in 260ms cubic-bezier(0.2, 0.85, 0.3, 1.1) calc(150ms + var(--n) * 45ms) both;
}
.option[data-on] {
  border-color: transparent;
  background: transparent;
  color: #f1eee4;
}
.option:focus-visible {
  outline: 2px solid var(--focus-on-dark);
  outline-offset: 5px;
}

/* The cursor: one frame that glides between options. A 1 px cream line with a glow outside and an
   olive light inside, and a solid triangle in each corner. */
.frame,
.cellFrame {
  position: absolute;
  z-index: 0;
  box-sizing: border-box;
  border: 1px solid var(--frame);
  background:
    linear-gradient(135deg, var(--frame-hot) 0 0.21em, transparent 0.21em) top left / 0.5em 0.5em
      no-repeat,
    linear-gradient(225deg, var(--frame-hot) 0 0.21em, transparent 0.21em) top right / 0.5em 0.5em
      no-repeat,
    linear-gradient(45deg, var(--frame-hot) 0 0.21em, transparent 0.21em) bottom left / 0.5em 0.5em
      no-repeat,
    linear-gradient(315deg, var(--frame-hot) 0 0.21em, transparent 0.21em) bottom right / 0.5em
      0.5em no-repeat,
    var(--option-on);
  box-shadow:
    inset 0 0 0 1px var(--frame-inner),
    inset 0 0 0.3em rgb(112 104 72 / 0.55),
    0 0 0 1px rgb(150 145 126 / 0.55);
  transition: translate 170ms cubic-bezier(0.3, 1.35, 0.5, 1);
  animation: frame-in 280ms cubic-bezier(0.2, 0.9, 0.3, 1.2) 240ms both;
}
/* The glow outside the frame, breathing. */
.frame::before,
.cellFrame::before {
  content: '';
  position: absolute;
  inset: -1px;
  box-shadow:
    0 0 0.3em 0.1em var(--frame-glow),
    0 0 0.75em 0.1em rgb(222 214 188 / 0.24);
  animation: breathe 1.7s ease-in-out infinite alternate;
}
/* The flash of a press. */
.frame::after,
.cellFrame::after {
  content: '';
  position: absolute;
  inset: 0;
  background: rgb(255 250 232 / 0.32);
  opacity: 0;
}
.frame {
  top: calc(0.875em - 2px);
  left: calc(0.875em - 2px);
  right: calc(0.875em - 2px);
  height: calc(2.3125em + 4px);
  translate: 0 calc(var(--i) * 3.125em);
}
.frame[data-pressed],
.cellFrame[data-pressed] {
  animation: press 220ms cubic-bezier(0.3, 1.4, 0.5, 1);
}
.frame[data-pressed]::after,
.cellFrame[data-pressed]::after {
  animation: flash 260ms ease-out;
}

/* ——— Message: the advance chevron ——— */
.advance {
  position: absolute;
  inset: 0;
  z-index: 1;
  padding: 0;
  border: 0;
  border-radius: inherit;
  background: none;
  cursor: pointer;
}
.advance:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 3px;
}
.chevronBox {
  position: absolute;
  left: 50%;
  bottom: -0.36em;
  width: 1.125em;
  height: 0.75em;
  margin-left: -0.5625em;
  color: #e7e4d9;
  animation: bob 900ms ease-in-out 400ms infinite alternate;
}
.chevronBox[data-pressed] {
  animation: dip 240ms cubic-bezier(0.3, 1.5, 0.5, 1);
}
.chevron {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}

/* ——— Item get ——— */
.card {
  position: relative;
  display: grid;
  grid-template-columns: 8.75em minmax(0, 1fr);
  box-sizing: border-box;
  width: 27.8125em;
  max-width: 100%;
  min-height: 10.4375em;
  padding: 0 1.875em 1.9em 0;
  border-radius: 0.4375em;
  background: var(--card);
  box-shadow:
    0 0 0 1px var(--edge),
    0 0.7em 1.8em var(--shade);
  --bracket-hole: var(--card);
}
.art {
  position: relative;
  /* The burst stops at the card's left edge, as it does at its top and bottom. */
  clip-path: inset(-20em -60em -20em 0);
}
.burstBloom {
  position: absolute;
  left: 4.6em;
  top: 5.3em;
  width: 9.6em;
  height: 9.6em;
  margin: -4.8em 0 0 -4.8em;
  animation: bloom 620ms cubic-bezier(0.2, 0.9, 0.3, 1.15) 60ms both;
}
.burst {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background:
    repeating-conic-gradient(var(--ray) 0deg, transparent 4deg, var(--ray) 8deg),
    radial-gradient(closest-side, var(--ray-core), transparent);
  mask: radial-gradient(closest-side, rgb(0 0 0 / 0.25) 10%, #000 34%, #000 46%, transparent 100%);
  animation: spin 24s linear infinite;
}
.itemGlyph {
  position: absolute;
  left: 4.65em;
  top: 5.05em;
  width: 9.4em;
  height: 9.4em;
  margin: -4.7em 0 0 -4.7em;
  filter: none;
  animation: glyph-pop 520ms cubic-bezier(0.2, 0.9, 0.3, 1.3) 160ms both;
}
.info {
  position: relative;
  z-index: 1;
  padding-top: 1.7em;
  min-width: 0;
}
.itemName {
  margin: 0;
  padding-bottom: 0.12em;
  border-bottom: 1px solid var(--card-rule);
  color: #ebe8dd;
  font-size: 1.375em;
  font-style: italic;
  font-weight: 700;
  line-height: 1.2;
  text-shadow: 0 1px 0 rgb(0 0 0 / 0.25);
}
.stats {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.375em;
  margin-top: 0.35em;
}
.statIcon {
  width: 1.05em;
  height: 1.05em;
}
.value,
.perk {
  display: inline-flex;
  align-items: center;
  height: 1.375em;
  box-sizing: border-box;
  border: 1px solid var(--box-edge);
  background: var(--box);
  font-size: 0.875em;
  font-weight: 500;
  line-height: 1;
}
.value {
  min-width: 2.5em;
  justify-content: center;
  padding: 0 0.4em;
  color: #f1eee5;
  font-variant-numeric: tabular-nums;
}
.perk {
  gap: 0.4em;
  padding: 0 0.55em 0 0.5em;
  color: var(--perk);
}
.perkIcon {
  width: 1em;
  height: 1em;
}
.desc {
  margin: 0.4em 0 0;
  color: #e9e6dc;
  font-size: 0.8125em;
  font-style: italic;
  line-height: 1.1;
}
.hit {
  position: absolute;
  inset: 0;
  z-index: 0;
  padding: 0;
  border: 0;
  border-radius: inherit;
  background: none;
  cursor: pointer;
}
.hit:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 3px;
}
.hit[data-pressed] {
  animation: card-flash 260ms ease-out;
}
.footer {
  position: absolute;
  right: 0.625em;
  bottom: 0.45em;
  z-index: 2;
  display: inline-flex;
  align-items: center;
  gap: 0.3em;
  padding: 0.1em 0.15em;
  border: 0;
  background: none;
  color: #efece2;
  font-size: 0.8125em;
  font-style: italic;
  font-weight: 600;
  cursor: pointer;
}
.footerGlyph {
  width: 1.05em;
  height: 1.05em;
}
.footer[data-pressed] .footerGlyph,
.close[data-pressed] .closeGlyph {
  animation: dip 240ms cubic-bezier(0.3, 1.5, 0.5, 1);
}
.footer:focus-visible,
.close:focus-visible {
  outline: 2px solid var(--focus-on-dark);
  outline-offset: 2px;
  border-radius: 3px;
}

/* ——— The satchel (list) and its Title / Description panel ——— */
.listWrap {
  display: flex;
  align-items: flex-start;
  gap: 1.25em;
}
.satchel {
  position: relative;
  box-sizing: border-box;
  width: 18.0625em;
  height: 25.625em;
  padding: 1em 0 0;
  border: 1px solid var(--satchel-edge);
  background: var(--satchel);
  box-shadow:
    inset 0 0 0 2px var(--satchel-line),
    0 0 0 1px var(--edge),
    0 0.7em 1.8em var(--shade);
}
.cells {
  position: relative;
  display: grid;
  gap: 0.5em;
  max-height: 22.625em;
  overflow-y: auto;
  scrollbar-width: none;
}
.row {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  gap: 0.75em;
  margin-left: 2.625em;
  padding: 0;
  border: 0;
  background: none;
  text-align: left;
  cursor: pointer;
  animation: option-in 260ms cubic-bezier(0.2, 0.85, 0.3, 1.1) calc(120ms + var(--n) * 45ms) both;
}
.row:focus-visible {
  outline: none;
}
.row:focus-visible .cell {
  outline: 2px solid var(--focus-on-dark);
  outline-offset: 6px;
}
.cell {
  display: grid;
  place-items: center;
  box-sizing: border-box;
  width: 4.125em;
  height: 4.125em;
  flex: none;
  border: 1px solid var(--cell-edge);
  background: var(--cell);
}
.row[data-primary] .cell {
  border-color: transparent;
  background: transparent;
}
.cellGlyph {
  width: 3.1em;
  height: 3.1em;
}
.rowName {
  color: var(--text);
  font-size: 0.8125em;
  font-style: italic;
  font-weight: 500;
}
.cellFrame {
  top: -2px;
  left: calc(2.625em - 2px);
  width: calc(4.125em + 4px);
  height: calc(4.125em + 4px);
  translate: 0 calc(var(--i) * 4.625em);
}
.close {
  position: absolute;
  right: 0.55em;
  bottom: 0.5em;
  display: inline-flex;
  align-items: center;
  gap: 0.35em;
  padding: 0.1em 0.15em;
  border: 0;
  background: none;
  color: var(--text);
  font-size: 0.8125em;
  font-style: italic;
  font-weight: 500;
  cursor: pointer;
}
.closeGlyph {
  width: 1.15em;
  height: 1.15em;
}
.detail {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.5em;
  width: 9.5em;
  padding-top: 1.1em;
}
.pill,
.pillWide {
  box-sizing: border-box;
  border-radius: 999px;
  background: var(--pill);
  color: var(--text);
  font-size: 0.875em;
  font-style: italic;
  font-weight: 500;
  line-height: 1.2;
  text-align: center;
  box-shadow: 0 0 0 1px var(--edge);
}
.pill {
  align-self: flex-start;
  margin-left: 1.45em;
  padding: 0.12em 0.75em;
  white-space: nowrap;
}
.square {
  display: grid;
  place-items: center;
  width: 5em;
  height: 5em;
  margin-top: -0.05em;
  border-radius: 0.125em;
  background: var(--pill);
  box-shadow: 0 0 0 1px var(--edge);
}
.squareGlyph {
  width: 3.8em;
  height: 3.8em;
}
.pillWide {
  margin-top: -0.3em;
  padding: 0.2em 0.85em;
  border-radius: 0.8em;
  max-width: 11em;
}
.pill,
.square,
.pillWide {
  animation-duration: 180ms;
  animation-delay: 0ms;
}

/* ——— The chips that open each kind ——— */
.kinds {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 6px;
}
.kind {
  min-height: 30px;
  padding: 0 12px;
  border: 1px solid var(--chip-edge);
  border-radius: 999px;
  background: var(--chip);
  color: var(--chip-text);
  font-size: 13px;
  font-weight: 500;
  cursor: pointer;
  transition:
    background-color 150ms,
    opacity 150ms,
    scale 150ms cubic-bezier(0.3, 1.5, 0.5, 1);
}
.kinds[inert] .kind {
  opacity: 0.5;
}
.kind:hover {
  background: var(--chip-hover);
}
.kind:active {
  scale: 0.95;
}
.kind:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

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

/* Narrow: the Title / Description panel goes under the satchel, side by side. */
@container (max-width: 520px) {
  .stage {
    height: 34em;
  }
  .listWrap {
    flex-direction: column;
    align-items: center;
    gap: 0.75em;
  }
  .detail {
    flex-direction: row;
    width: auto;
    max-width: 18.0625em;
    padding-top: 0;
    gap: 0.6em;
  }
  .square {
    order: -1;
    width: 3.6em;
    height: 3.6em;
    flex: none;
  }
  .squareGlyph {
    width: 2.8em;
    height: 2.8em;
  }
  .pill {
    display: none;
  }
  .pillWide {
    margin: 0;
    text-align: left;
  }
  .kind {
    min-height: 28px;
    padding: 0 10px;
    font-size: 12px;
  }
}

/* Endless loops rest while nobody can see them. */
.root[data-paused] .burst,
.root[data-paused] .chevronBox,
.root[data-paused] .frame::before,
.root[data-paused] .cellFrame::before {
  animation-play-state: paused;
}

@keyframes rise {
  from {
    opacity: 0;
    transform: translateY(0.9em) scale(0.94);
  }
  55% {
    opacity: 1;
  }
}
@keyframes sink {
  to {
    opacity: 0;
    transform: translateY(0.45em) scale(0.97);
  }
}
@keyframes bracket-in {
  from {
    opacity: 0;
    translate: var(--dx) var(--dy);
  }
}
@keyframes text-in {
  from {
    opacity: 0;
    translate: 0 0.45em;
  }
}
@keyframes option-in {
  from {
    opacity: 0;
    translate: -0.7em 0;
  }
}
@keyframes frame-in {
  from {
    opacity: 0;
    scale: 1.08;
  }
}
@keyframes breathe {
  from {
    opacity: 0.55;
  }
  to {
    opacity: 1;
  }
}
@keyframes press {
  40% {
    scale: 0.965;
  }
}
@keyframes flash {
  20% {
    opacity: 1;
  }
}
@keyframes card-flash {
  20% {
    background: rgb(255 248 225 / 0.12);
  }
}
@keyframes dip {
  40% {
    scale: 0.8;
  }
}
@keyframes bob {
  to {
    translate: 0 0.22em;
  }
}
@keyframes bloom {
  from {
    opacity: 0;
    scale: 0.2;
  }
  60% {
    opacity: 1;
    scale: 1.12;
  }
}
@keyframes spin {
  to {
    rotate: 360deg;
  }
}
@keyframes glyph-pop {
  from {
    opacity: 0;
    scale: 0.5;
    translate: 0 -0.6em;
  }
  60% {
    opacity: 1;
    scale: 1.07;
    translate: 0 0.1em;
  }
}

/* Reduced motion: no rises, slides, pops, glides, turns or bobs; things fade in and out. */
@media (prefers-reduced-motion: reduce) {
  .dialog,
  .corner,
  .title,
  .subtitle,
  .body,
  .itemName,
  .stats,
  .desc,
  .footer,
  .close,
  .pill,
  .square,
  .pillWide,
  .option,
  .row,
  .frame,
  .cellFrame,
  .burstBloom,
  .itemGlyph {
    animation: fade 140ms ease both;
  }
  .dialog[data-phase='out'] {
    animation: fade-out 60ms ease forwards;
  }
  .frame,
  .cellFrame {
    transition: none;
  }
  .burst,
  .chevronBox,
  .frame::before,
  .cellFrame::before {
    animation: none;
  }
  .idle {
    transition: opacity 120ms ease;
  }
  .idle[data-hidden] {
    scale: none;
  }
}
@keyframes fade {
  from {
    opacity: 0;
  }
}
@keyframes fade-out {
  to {
    opacity: 0;
  }
}
```

### game-modal/glyphs.tsx

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

import { useId } from 'react';

/*
 * The modal's art, drawn as small SVGs (the CSS sizes each one; every drawing is aria-hidden):
 *
 * - Bracket: the ornamental corner, a chamfered L with a solid triangle in its corner and a pinhole
 *   in that triangle. Drawn for the top-left corner; the CSS turns it for the other three.
 * - Chevron: the advance mark at the foot of a message, an outlined triangle with a small solid one.
 * - Face: a controller face button (A, B, +), a light disc with a dark letter.
 * - Stat and perk icons: flat white or green marks.
 * - Item glyphs: a blade and five satchel items, flat and bold with one highlight each.
 */

export type ItemGlyphName =
  'blade' | 'emberfruit' | 'capshroom' | 'frostherb' | 'riverfish' | 'amber';
export type StatIcon = 'power' | 'guard';
export type PerkIcon = 'cold' | 'climb' | 'speed';

export function Bracket({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 19 19" aria-hidden="true">
      <path d="M18.5 0.65H9.4L0.65 9.4V18.5" fill="none" stroke="currentColor" strokeWidth="1.3" />
      <path d="M0 0H6.6L0 6.6Z" fill="currentColor" />
      <rect x="1.4" y="1.4" width="1.3" height="1.3" fill="var(--bracket-hole)" />
    </svg>
  );
}

export function Chevron({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 18 12" aria-hidden="true">
      <path
        d="M1.2 1H16.8L9 10.8Z"
        fill="var(--chevron-fill)"
        stroke="currentColor"
        strokeWidth="1.4"
        strokeLinejoin="round"
      />
      <path d="M6.4 3.4H11.6L9 6.6Z" fill="currentColor" />
    </svg>
  );
}

export function Face({ button, className }: { button: 'A' | 'B' | '+'; className?: string }) {
  return (
    <svg className={className} viewBox="0 0 32 32" aria-hidden="true">
      <circle cx="16" cy="16" r="16" fill="var(--glyph)" />
      {button === '+' ? (
        <path d="M16 8.5v15M8.5 16h15" stroke="var(--glyph-ink)" strokeWidth="3.6" />
      ) : (
        <text
          x="16"
          y="22.8"
          textAnchor="middle"
          fontSize="19"
          fill="var(--glyph-ink)"
          style={{ fontWeight: 700, fontStyle: 'normal' }}
        >
          {button}
        </text>
      )}
    </svg>
  );
}

export function Stat({ icon, className }: { icon: StatIcon; className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">
      {icon === 'power' ? (
        <>
          <path d="M13.8 1.2 L14.8 2.2 L7.3 9.7 L5.6 9.4 L6.3 8.7Z" fill="#f4f2ea" />
          <path d="M3.2 7.6 L8.4 12.8 L7.2 14 L2 8.8Z" fill="#f4f2ea" />
          <path d="M4.6 10.4 L5.6 11.4 L2.6 14.4 L1.6 13.4Z" fill="#f4f2ea" />
        </>
      ) : (
        <path
          d="M8 1.3 L14 3.4 V7.6 C14 11 11.4 13.4 8 14.8 C4.6 13.4 2 11 2 7.6 V3.4Z"
          fill="#f4f2ea"
        />
      )}
    </svg>
  );
}

export function Perk({ icon, className }: { icon: PerkIcon; className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">
      {icon === 'climb' ? (
        <path
          d="M1 13.5 L6.2 4.5 L8.6 8.6 L10.4 6.4 L15 13.5Z M5.2 10.6 l1-1.6 l1 1.6Z"
          fill="currentColor"
        />
      ) : icon === 'speed' ? (
        <path d="M9.5 1.2 L3 9.2 H7.4 L6.2 14.8 L13 6.6 H8.6Z" fill="currentColor" />
      ) : (
        <g stroke="currentColor" strokeWidth="1.7" strokeLinecap="round">
          <path d="M8 1.6V14.4M2.5 4.8 13.5 11.2M2.5 11.2 13.5 4.8" />
          <path d="M6 2.8 8 4.6 10 2.8M6 13.2 8 11.4 10 13.2" fill="none" strokeWidth="1.3" />
        </g>
      )}
    </svg>
  );
}

export function ItemGlyph({ name, className }: { name: ItemGlyphName; className?: string }) {
  const id = useId().replace(/:/g, '');
  return (
    <svg className={className} viewBox="0 0 64 64" aria-hidden="true">
      {name === 'blade' && <Blade id={id} />}
      {name === 'emberfruit' && <Emberfruit id={id} />}
      {name === 'capshroom' && <Capshroom id={id} />}
      {name === 'frostherb' && <Frostherb id={id} />}
      {name === 'riverfish' && <Riverfish id={id} />}
      {name === 'amber' && <Amber id={id} />}
    </svg>
  );
}

/* A long sword drawn lying flat, then turned to point up and right. */
function Blade({ id }: { id: string }) {
  return (
    <g transform="rotate(-45 32 32)" strokeLinejoin="round">
      <defs>
        <linearGradient id={`${id}b`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#dff8f2" />
          <stop offset="0.5" stopColor="#a9e2d8" />
          <stop offset="0.51" stopColor="#7cc3b8" />
          <stop offset="1" stopColor="#5fa69c" />
        </linearGradient>
      </defs>
      <path
        d="M21 28.6H56.5L62.4 32L56.5 35.4H21Z"
        fill={`url(#${id}b)`}
        stroke="#23343a"
        strokeWidth="0.9"
      />
      <path d="M24 32H55" stroke="#5a9c93" strokeWidth="0.8" />
      <rect
        x="16.4"
        y="22"
        width="4.8"
        height="20"
        rx="1.4"
        fill="#3c4a86"
        stroke="#1d2340"
        strokeWidth="0.9"
      />
      <path d="M16.4 22 l-2.4 -2.6 h3.2Z M16.4 42 l-2.4 2.6 h3.2Z" fill="#3c4a86" />
      <circle cx="18.8" cy="32" r="1.7" fill="#f3c34f" />
      <rect
        x="6.2"
        y="29.6"
        width="10.4"
        height="4.8"
        rx="1"
        fill="#2a2f4a"
        stroke="#161a2b"
        strokeWidth="0.8"
      />
      <path d="M9 29.8v4.4M12 29.8v4.4M14.6 29.8v4.4" stroke="#4a5277" strokeWidth="0.8" />
      <circle cx="4.4" cy="32" r="3.1" fill="#3c4a86" stroke="#1d2340" strokeWidth="0.9" />
      <circle cx="4" cy="31.2" r="0.9" fill="#f3c34f" />
    </g>
  );
}

function Emberfruit({ id }: { id: string }) {
  return (
    <g>
      <defs>
        <radialGradient id={`${id}f`} cx="0.38" cy="0.36" r="0.7">
          <stop offset="0" stopColor="#ff8a5c" />
          <stop offset="0.55" stopColor="#e9402c" />
          <stop offset="1" stopColor="#9c1f1a" />
        </radialGradient>
      </defs>
      <path
        d="M32 19C26 15 13 16 13 31C13 45 22 54 28 54C30 54 31 53 32 53C33 53 34 54 36 54C42 54 51 45 51 31C51 16 38 15 32 19Z"
        fill={`url(#${id}f)`}
      />
      <path
        d="M32 20C31.6 15 30.4 11.6 28.4 9"
        stroke="#5a3a2a"
        strokeWidth="2.2"
        strokeLinecap="round"
        fill="none"
      />
      <path d="M33 16C37 10 43 10 46 12C42 17 37 18 33 16Z" fill="#6ab04c" />
      <ellipse
        cx="23"
        cy="28"
        rx="3.6"
        ry="6"
        fill="#ffd2bd"
        opacity="0.55"
        transform="rotate(20 23 28)"
      />
    </g>
  );
}

function Capshroom({ id }: { id: string }) {
  return (
    <g>
      <defs>
        <linearGradient id={`${id}c`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#d9613c" />
          <stop offset="1" stopColor="#8f2e1c" />
        </linearGradient>
      </defs>
      <path d="M26 36H38L40 54C36 56.4 28 56.4 24 54Z" fill="#efe4c8" />
      <path d="M30 37H34L35 54C33 55 31 55 29 54Z" fill="#d8c9a5" opacity="0.7" />
      <path d="M8 36C8 20 20 10 32 10C44 10 56 20 56 36C46 39 18 39 8 36Z" fill={`url(#${id}c)`} />
      <circle cx="22" cy="22" r="3.4" fill="#fff4dc" />
      <circle cx="36" cy="17" r="2.6" fill="#fff4dc" />
      <circle cx="45" cy="27" r="3" fill="#fff4dc" />
      <circle cx="30" cy="29" r="2.2" fill="#fff4dc" />
    </g>
  );
}

function Frostherb({ id }: { id: string }) {
  return (
    <g>
      <defs>
        <linearGradient id={`${id}h`} x1="0" y1="0" x2="1" y2="1">
          <stop offset="0" stopColor="#bfe8ff" />
          <stop offset="1" stopColor="#4f8fd6" />
        </linearGradient>
      </defs>
      <path
        d="M32 56C32 44 31 34 32 22"
        stroke="#4d8a4a"
        strokeWidth="2.6"
        strokeLinecap="round"
        fill="none"
      />
      <path d="M31 44C24 44 17 40 14 33C22 32 28 36 31 44Z" fill="#5fa85a" />
      <path d="M33 40C40 39 46 34 49 27C41 27 35 32 33 40Z" fill="#5fa85a" />
      <path d="M32 24C27 20 25 13 28 7C33 10 35 17 32 24Z" fill={`url(#${id}h)`} />
      <path d="M32 24C34 18 40 13 47 14C45 20 39 24 32 24Z" fill={`url(#${id}h)`} />
      <path d="M32 24C30 18 24 13 17 14C19 20 25 24 32 24Z" fill={`url(#${id}h)`} />
      <circle cx="32" cy="22" r="2.6" fill="#f5fbff" />
    </g>
  );
}

function Riverfish({ id }: { id: string }) {
  return (
    <g>
      <defs>
        <linearGradient id={`${id}r`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#5d8fc9" />
          <stop offset="0.6" stopColor="#9cc4e4" />
          <stop offset="1" stopColor="#e6f0f4" />
        </linearGradient>
      </defs>
      <path d="M48 32L60 21V43Z" fill="#4b77ad" />
      <path
        d="M6 32C12 21 24 17 34 18C42 19 48 25 50 32C48 39 42 45 34 46C24 47 12 43 6 32Z"
        fill={`url(#${id}r)`}
      />
      <path d="M26 19C30 13 36 12 40 14L36 20Z" fill="#4b77ad" />
      <circle cx="15" cy="29" r="2.4" fill="#1a2230" />
      <circle cx="14.4" cy="28.4" r="0.8" fill="#fff" />
      <path
        d="M22 26C25 30 25 34 22 38"
        stroke="#3f6797"
        strokeWidth="1.6"
        fill="none"
        strokeLinecap="round"
      />
    </g>
  );
}

function Amber({ id }: { id: string }) {
  return (
    <g strokeLinejoin="round">
      <defs>
        <linearGradient id={`${id}a`} x1="0" y1="0" x2="1" y2="1">
          <stop offset="0" stopColor="#ffe08a" />
          <stop offset="0.5" stopColor="#f2a52c" />
          <stop offset="1" stopColor="#b3600f" />
        </linearGradient>
      </defs>
      <path d="M32 6L52 22L46 52H18L12 22Z" fill={`url(#${id}a)`} />
      <path d="M32 6L40 24H24Z" fill="#fff2c2" opacity="0.7" />
      <path d="M24 24H40L46 52H18Z" fill="#c8761a" opacity="0.35" />
      <path d="M12 22L24 24L18 52Z" fill="#ffd66b" opacity="0.5" />
    </g>
  );
}
```

### game-modal/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. `visible` alone says whether it is on screen
 * in a visible tab, for clocks that should stop when nobody can see them.
 */
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 };
}
```