# Title screen

> A game’s first screen on a navy tablet bezel: a painted sunset cliff with a lone traveller, a serif wordmark, “Press any button”, and a right-aligned menu with a sliding pointer.

The screen a game opens on. A painted landscape fills it: a lone traveller seen from behind on a rock at a cliff edge, a valley, ranges fading into haze and clouds lit by a low sun, all in vector layers that drift slowly in parallax. On the intro the wordmark sits big in the middle and “Press any button” pulses under it; any key, click, tap or gamepad button starts, and the wordmark glides to the top right while the menu slides in from the right. Continue is preselected, the bright item with a pointer glyph before it; the arrows move the pointer, A or Enter chooses, B or Escape goes back to the intro. Version lines sit in the corner. The screen sits in an original navy bezel with a cyan rim.

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

Category: Games · Screens.

## Anatomy

1. **Bezel**: An original navy device, 16 units deep (10 on the narrow screen), with a cyan rim around the glass and small cyan studs and bars; only its edge and shadow follow the page’s scheme.
2. **Screen**: A 960 × 540 `role="group"` named after the game (392 × 640 under 600 px), filled by the painted sunset whose layers drift by depth.
3. **Wordmark**: Kicker, name and tagline in a serif, centred and 1.45× on the intro; starting glides it to the top right in 760 ms. The name’s size follows its length.
4. **Menu**: A right-aligned `role="menu"` of italic `menuitem` buttons on a 30-unit pitch, sliding in from the right 60 ms apart; only the selected item is in the tab order.
5. **Selected item**: The bright item, with a faint warm glow; a hover or the arrows move the selection, and A, Enter or a click chooses it, flashing it and calling `onChoose`.
6. **Pointer**: One feather glyph, slid to the selected row with a small overshoot and placed 12 units before its measured words; at either end it only nudges.
7. **Version lines**: The `version` lines, small and italic in the bottom right corner, fading in once the menu is open.

## When to use

- The first screen of a game or a game-like experience: the name, a call to start, then the few ways in (continue, new game, options, extras).
- A boot or attract screen that waits on its own and starts on any key, click, tap or gamepad button.
- A short main menu read with a controller: up and down move one pointer, A chooses, B goes back to the intro.
- A launch screen that should set the mood with a painted scene and a wordmark before any choice is asked of the player.

## When not to use

- The landing page of a site or an app. Use a hero with a heading and a call to action; nothing here is scrollable or indexable.
- A pause menu over a game in progress. Use a system menu or menu tabs, which sit over the play and list more pages.
- Choosing between many entries, saves or levels. Use a list with a selection frame (a save list or a quest log), which scrolls.
- A splash that only shows a logo while something loads. Use a loading screen with progress; this one waits for the player.
- Choosing one of a few answers in a conversation. Use a choice list beside the dialogue box.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/title-screen.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 { TitleScreen } from './title-screen';

export function Boot({ onContinue }: { onContinue: () => void }) {
  return (
    <TitleScreen
      autoplay={false}
      logo={{ kicker: 'The Song of', name: 'Eventide', tagline: 'Beyond the Far Ridge' }}
      onChoose={(item) => {
        if (item.id === 'continue') onContinue();
      }}
    />
  );
}
```

## States

- **Intro**: `defaultPhase="intro"`: the wordmark big in the middle and “Press any button” pulsing under it; the menu is hidden.
- **Menu**: `defaultPhase="menu"`: the wordmark at the top right, Continue selected and the version lines in the corner.
- **Another item selected**: `defaultSelected={2}`: the pointer before Options, placed by that item’s measured width.
- **Last item**: `defaultSelected={3}`: the end of the menu; pressing down only nudges the pointer.
- **Three items**: Custom `items`: the menu keeps its top and grows down by the pitch.
- **Long name**: A 14-letter name: its size follows its length, so it stays inside the wordmark’s 250 units.
- **Name only**: A `logo` with no kicker and no tagline: the name alone, centred in the same box.
- **Narrow, intro**: Under 600 px the screen turns 392 × 640: the painting keeps its height and is cropped around the traveller.
- **Narrow, menu**: The narrow menu: the wordmark at the top, the items on a 33-unit pitch above the corner lines.
- **Light scheme**: `colorScheme="light"`: a soft navy shadow and no edge; the screen keeps its own colours.
- **Dark scheme**: `colorScheme="dark"`: a faint light edge and a deeper shadow around the bezel.

## How it works

- The device is one drawing in design units: --u is min(1px, 100cqi / (960 + 2 × 16)), from the stage’s width by a container query unit, and every size is a multiple of it, so the 960 × 540 screen and its 16-unit bezel scale as a whole. Under 600 px the screen becomes a 392 × 640 portrait one: the painting keeps its height and is cropped around the traveller, and the wordmark, menu and prompt take portrait positions.
- The painting is a single SVG (art.tsx) in layers, back to front: a sky gradient with the sun’s halo and light shafts, a dark sweep and pale streaks of high cloud, cumulus heaps built from arcs (shade, body, and lit heaps toward the sun), three mountain ranges from midpoint-displacement ridges with a fixed seed (so server and browser draw the same), rim-lit and hazier with distance, valley mist, groves and a river, the faceted cliff with grass, and the traveller, lit along his sun side by a warm copy drawn under the dark silhouette.
- Parallax: each layer carries its depth as --k, and one 28 s keyframe pans every layer by --k × 16 units, so far ranges barely move and the cliff and foreground move most. The clouds, mist, birds, halo and rays have their own slower loops, and the whole painting breathes by a slight scale over 48 s.
- Starting flashes the prompt (420 ms), pulses the cyan rim, glides the wordmark from the centre to the top right by translate and scale (760 ms, ease-out), then slides the menu items in from the right 60 ms apart and fades the version lines in.
- The pointer is one glyph in the menu, moved by translate to the selected row on a 30-unit pitch with an overshooting curve (190 ms), and placed just before the item’s words from their measured width (the items are right-aligned and of different lengths). At either end it only nudges; choosing flashes the item and darts the pointer.
- Input: on the intro any key but Tab and the modifiers, a click or tap, or any gamepad button starts; on the menu the arrows or W and S move, A, Enter or Space choose, B, Escape or Backspace go back, a mouse hover moves the pointer and a click chooses. A connected gamepad’s d-pad, stick, A and B work too.
- Uncontrolled, a demo waits on the intro, starts, browses down and up, chooses Continue and backs out, while the screen is on view and untouched; any real input hands it to the reader. Off screen or in a hidden tab every animation pauses.

## API reference

- **items** (`TitleMenuItem[]`, default `Continue, New Game, Options, Extras`): The menu, top to bottom: { id, label }.
- **logo** (`TitleLogo`, default `{ kicker: 'The Song of', name: 'Eventide', tagline: 'Beyond the Far Ridge' }`): The wordmark: a small kicker line, the name (its size follows its length) and a tagline.
- **version** (`string[]`, default `['Ver. 1.4.2', 'Data Ver. 3.0', '© 2026 Eventide']`): The lines in the bottom right corner, shown on the menu.
- **prompt** (`string`, default `'Press any button'`): The intro’s call to start.
- **selected** (`number`): The item under the pointer, controlled, as an index into items. Turns the demo off.
- **defaultSelected** (`number`, default `0`): Uncontrolled, the item the pointer starts on.
- **defaultPhase** (`'intro' | 'menu'`, default `'intro'`): Opens on the intro or straight on the menu. Set, it turns the demo off.
- **onSelect** (`(index: number) => void`): Called when the pointer moves to another item.
- **onChoose** (`(item: TitleMenuItem, index: number) => void`): Called when an item is chosen.
- **onStart** (`() => void`): Called when the player presses a button on the intro.
- **onBack** (`() => void`): Called when the player backs out of the menu to the intro.
- **drift** (`boolean`, default `true`): The slow parallax drift of the painting, its clouds and birds.
- **keys** (`boolean`, default `true`): Listen to the keyboard (while focus is inside) and to a connected gamepad.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a short demo: start, browse, choose, back out. Any real input stops it.
- **colorScheme** (`'light' | 'dark' | 'system'`): Sets color-scheme on the root: the bezel’s edge, its shadow and the focus ring follow it; the screen keeps its own colours.
- **className** (`string`): Added to the root.

## Accessibility

- The screen is a labelled group named after the game; the intro is a real button (“Press any button”), and the menu is a role="menu" of menuitem buttons with a roving tabindex, so Tab reaches the selected item and the arrows move along.
- Focus follows the screen while it is inside: to the selected item when the menu opens and as the pointer moves, back to the start button on the intro. A focus ring rings the device, and a focused item gets its own outline.
- A polite live region says “Main menu. Continue.” on start, each item as the pointer reaches it, “… chosen.” on a choice, and the prompt again on going back.
- The painting, the pointer glyph, the ornaments and the flashes are aria-hidden; the wordmark is text.
- Under reduced motion there is no drift, no demo and no slides: the states swap at once. Every animation pauses off screen or in a hidden tab, and drift={false} stills the painting.

## Source

### title-screen/title-screen.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
} from 'react';
import { TitleArt } from './art';
import { useAutoplay } from './use-autoplay';
import styles from './title-screen.module.css';

/*
 * Title screen: the first screen of a game, on a tablet-like device of its own (a navy bezel with
 * a cyan rim and small ornaments). A painted sunset landscape (art.tsx) fills the screen: a lone
 * traveller seen from behind on a rock at a cliff edge, the valley and its ranges beyond. Its
 * layers pan together by depth, slowly, and the clouds and birds drift on their own loops.
 *
 * - Intro: the game's wordmark sits big in the middle and "Press any button" pulses under it. Any
 *   key, a click or tap anywhere on the screen, or any gamepad button starts.
 * - Starting flashes the prompt, glides the wordmark to the top right, and slides the menu in from
 *   the right, item by item, with the version lines in the corner. Continue is preselected: the
 *   bright item, with the pointer glyph before it.
 * - The arrows or W/S (a d-pad or stick) move the pointer, which slides with a small overshoot;
 *   at either end it only nudges. A mouse hover moves it too. A, Enter or Space (or a click)
 *   chooses: the item flashes and `onChoose` is called. B, Escape or Backspace go back to the
 *   intro.
 * - Uncontrolled, a demo waits on the intro, starts, browses down and up, chooses Continue and
 *   backs out, while the screen is on view and untouched; any real input hands it to the reader.
 * - A whole game screen keeps its own colours on a light or a dark page; only the bezel's edge,
 *   its shadow and the focus ring follow the scheme. Sizes are design units of a 960 × 540 screen
 *   (392 × 640 on a narrow stage, with the painting cropped around the traveller), scaled to fit.
 */

export type TitleMenuItem = {
  /** A stable key, passed back to onChoose. */
  id: string;
  /** The item's words. */
  label: string;
};

export type TitleLogo = {
  /** The small line over the name. */
  kicker?: string;
  /** The game's name, the big word. */
  name: string;
  /** The line under the name. */
  tagline?: string;
};

const DEFAULT_ITEMS: TitleMenuItem[] = [
  { id: 'continue', label: 'Continue' },
  { id: 'new-game', label: 'New Game' },
  { id: 'options', label: 'Options' },
  { id: 'extras', label: 'Extras' },
];
const DEFAULT_LOGO: TitleLogo = {
  kicker: 'The Song of',
  name: 'Eventide',
  tagline: 'Beyond the Far Ridge',
};
const DEFAULT_VERSION = ['Ver. 1.4.2', 'Data Ver. 3.0', '© 2026 Eventide'];

type Phase = 'intro' | 'menu';
type Action = 'up' | 'down' | 'confirm' | 'cancel' | 'any';

/** The demo: actions at ms from the start of each run, and the run's length. */
const DEMO: [number, Action | 'reset'][] = [
  [0, 'reset'],
  [2600, 'any'],
  [4300, 'down'],
  [5000, 'down'],
  [5700, 'down'],
  [6400, 'down'],
  [7300, 'up'],
  [7900, 'up'],
  [8500, 'up'],
  [9300, 'confirm'],
  [11200, 'cancel'],
];
const DEMO_LENGTH = 13200;

/** A standard-layout gamepad: right face (A), bottom face (B), d-pad up and down. */
const PAD: [number, Action][] = [
  [1, 'confirm'],
  [0, 'cancel'],
  [12, 'up'],
  [13, 'down'],
];

function keyAction(event: ReactKeyboardEvent, phase: Phase): Action | null {
  if (event.metaKey || event.ctrlKey || event.altKey) return null;
  if (phase === 'intro') {
    // Any button, but not the keys that move focus or only modify.
    if (['Tab', 'Shift', 'CapsLock', 'Meta', 'Control', 'Alt'].includes(event.key)) return null;
    return 'any';
  }
  switch (event.key) {
    case 'ArrowUp':
    case 'w':
    case 'W':
      return 'up';
    case 'ArrowDown':
    case 's':
    case 'S':
      return 'down';
    case 'Enter':
    case ' ':
    case 'a':
    case 'A':
      return 'confirm';
    case 'Escape':
    case 'Backspace':
    case 'b':
    case 'B':
      return 'cancel';
  }
  return null;
}

export function TitleScreen({
  items = DEFAULT_ITEMS,
  logo = DEFAULT_LOGO,
  version = DEFAULT_VERSION,
  prompt = 'Press any button',
  selected,
  defaultSelected = 0,
  defaultPhase,
  onSelect,
  onChoose,
  onStart,
  onBack,
  drift = true,
  keys = true,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The menu, top to bottom. */
  items?: TitleMenuItem[];
  /** The game's wordmark: a kicker, the name and a tagline. */
  logo?: TitleLogo;
  /** The lines in the bottom right corner (version, data, rights). */
  version?: string[];
  /** The intro's call to start. */
  prompt?: string;
  /** The item under the pointer, controlled (an index into `items`). */
  selected?: number;
  /** Uncontrolled, the item the pointer starts on. */
  defaultSelected?: number;
  /** Opens on the intro or straight on the menu. Set, it also turns the demo off. */
  defaultPhase?: Phase;
  /** Called when the pointer moves to another item. */
  onSelect?: (index: number) => void;
  /** Called when an item is chosen. */
  onChoose?: (item: TitleMenuItem, index: number) => void;
  /** Called when the player presses a button on the intro. */
  onStart?: () => void;
  /** Called when the player backs out of the menu to the intro. */
  onBack?: () => void;
  /** The slow parallax drift of the painting, clouds and birds. */
  drift?: boolean;
  /** Listen to the keyboard (while focus is inside) and to a connected gamepad. */
  keys?: boolean;
  /** While on screen and untouched, a short demo: start, browse, choose, back out. */
  autoplay?: boolean;
  /** Light or dark, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, visible, reduced, take } = useAutoplay<HTMLDivElement>(7000);
  const uid = useId().replace(/[^a-zA-Z0-9-]/g, '');
  const startRef = useRef<HTMLButtonElement>(null);
  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const controlled = selected !== undefined;
  const demo = autoplay && playing && !controlled && defaultPhase === undefined;

  const count = items.length;
  const clamp = (index: number) => Math.min(Math.max(index, 0), Math.max(count - 1, 0));
  const [own, setOwn] = useState(() => clamp(defaultSelected));
  const current = clamp(selected ?? own);

  const [phase, setPhase] = useState<Phase>(defaultPhase ?? 'intro');
  const [bump, setBump] = useState<{ n: number; dir: 'up' | 'down' }>({ n: 0, dir: 'up' });
  const [chosen, setChosen] = useState(0);
  const [pressed, setPressed] = useState(0);
  const [said, setSaid] = useState('');

  const timers = useRef(new Set<number>());
  const later = (ms: number, run: () => void) => {
    const timer = window.setTimeout(() => {
      timers.current.delete(timer);
      run();
    }, ms);
    timers.current.add(timer);
  };
  useEffect(() => {
    const all = timers.current;
    return () => all.forEach((timer) => window.clearTimeout(timer));
  }, []);

  const select = (index: number, fromDemo = false) => {
    const next = clamp(index);
    if (next === current) return;
    if (!controlled) setOwn(next);
    if (!fromDemo) onSelect?.(next);
  };

  const act = useEffectEvent((action: Action, fromDemo = false) => {
    if (phase === 'intro') {
      if (action === 'cancel' && !fromDemo) return;
      setPressed((n) => n + 1);
      setPhase('menu');
      setSaid(`Main menu. ${items[current]?.label ?? ''}.`);
      if (!fromDemo) onStart?.();
      return;
    }
    if (action === 'up' || action === 'down') {
      const next = current + (action === 'up' ? -1 : 1);
      if (next < 0 || next >= count) setBump((b) => ({ n: b.n + 1, dir: action }));
      else {
        select(next, fromDemo);
        setSaid(items[next].label);
      }
    } else if (action === 'confirm') {
      const item = items[current];
      if (!item) return;
      setChosen((n) => n + 1);
      setSaid(`${item.label} chosen.`);
      if (!fromDemo) onChoose?.(item, current);
    } else if (action === 'cancel') {
      setPhase('intro');
      setSaid(prompt);
      if (!fromDemo) onBack?.();
    }
  });

  // The demo, again and again while it may play.
  useEffect(() => {
    if (!demo) return;
    const run = () => {
      for (const [at, step] of DEMO) {
        later(at, () => {
          if (step !== 'reset') return act(step, true);
          setOwn(0);
          setPhase('intro');
          setSaid('');
        });
      }
    };
    run();
    const loop = window.setInterval(run, DEMO_LENGTH);
    const all = timers.current;
    return () => {
      window.clearInterval(loop);
      all.forEach((timer) => window.clearTimeout(timer));
      all.clear();
    };
  }, [demo]);

  // Focus follows the screen while it is inside: the selected item on the menu, the start button
  // on the intro.
  useEffect(() => {
    const element = root.current;
    if (!element || !element.contains(document.activeElement)) return;
    if (phase === 'menu') itemRefs.current[current]?.focus({ preventScroll: true });
    else startRef.current?.focus({ preventScroll: true });
  }, [phase, current, root]);

  const onKeyDown = (event: ReactKeyboardEvent) => {
    if (!keys) return;
    const action = keyAction(event, phase);
    if (!action) return;
    event.preventDefault();
    take();
    if (event.repeat && action !== 'up' && action !== 'down') return;
    act(action);
  };

  // A gamepad: polled once one is connected (browsers only report one after a user gesture).
  const [pad, setPad] = useState(false);
  useEffect(() => {
    if (!keys || !('getGamepads' in navigator)) return;
    const connected = () => setPad(navigator.getGamepads().some(Boolean));
    connected();
    window.addEventListener('gamepadconnected', connected);
    window.addEventListener('gamepaddisconnected', connected);
    return () => {
      window.removeEventListener('gamepadconnected', connected);
      window.removeEventListener('gamepaddisconnected', connected);
    };
  }, [keys]);
  const held = useRef(new Set<string>());
  const poll = useEffectEvent(() => {
    const now = new Set<string>();
    let any = false;
    navigator.getGamepads().forEach((gamepad) => {
      if (!gamepad) return;
      if (gamepad.buttons.some((button) => button.pressed)) any = true;
      for (const [index, action] of PAD) if (gamepad.buttons[index]?.pressed) now.add(action);
      const y = gamepad.axes[1] ?? 0;
      if (y < -0.5) now.add('up');
      if (y > 0.5) now.add('down');
    });
    if (any) now.add('any');
    for (const action of now) {
      if (held.current.has(action)) continue;
      if (phase === 'intro' ? action !== 'any' : action === 'any') continue;
      take();
      act(action as Action);
    }
    held.current = now;
  });
  useEffect(() => {
    if (!pad || !visible) return;
    let frame = requestAnimationFrame(function tick() {
      poll();
      frame = requestAnimationFrame(tick);
    });
    return () => cancelAnimationFrame(frame);
  }, [pad, visible]);

  // The pointer sits just before the selected item's words: the items are right-aligned and of
  // different lengths, so their widths are measured (again whenever one resizes, as fonts load or
  // the stage scales).
  const [widths, setWidths] = useState<number[]>([]);
  useEffect(() => {
    const buttons = itemRefs.current.slice(0, count).filter(Boolean) as HTMLButtonElement[];
    const measure = () =>
      setWidths((was) => {
        const now = buttons.map((button) => button.offsetWidth);
        return now.length === was.length && now.every((w, i) => w === was[i]) ? was : now;
      });
    measure();
    const observer = new ResizeObserver(measure);
    buttons.forEach((button) => observer.observe(button));
    return () => observer.disconnect();
  }, [count, items]);

  const menu = phase === 'menu';
  const name = logo.name;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      data-phase={phase}
      data-reduced={reduced || undefined}
      data-paused={visible ? undefined : ''}
      data-still={drift ? undefined : ''}
      onKeyDown={onKeyDown}
    >
      <div className={styles.device} data-part="bezel">
        <span className={styles.ornaments} aria-hidden="true">
          <i data-at="tl" />
          <i data-at="tr" />
          <i data-at="bl" />
          <i data-at="br" />
          <b data-at="top" />
          <b data-at="bottom" />
        </span>
        <div
          className={styles.screen}
          data-part="screen"
          data-phase={phase}
          role="group"
          aria-roledescription="title screen"
          aria-label={name}
          aria-keyshortcuts={keys ? 'ArrowUp ArrowDown A B Enter Escape' : undefined}
        >
          <div className={styles.paint} aria-hidden="true">
            <TitleArt id={`${uid}-art`} className={styles.art} />
          </div>
          <div className={styles.shade} aria-hidden="true" />

          <div
            className={styles.logo}
            data-part="logo"
            style={{ '--len': Math.max(name.length, 5) } as CSSProperties}
          >
            {logo.kicker && (
              <span className={styles.kicker} data-part="kicker">
                <i aria-hidden="true" />
                {logo.kicker}
                <i aria-hidden="true" />
              </span>
            )}
            <span className={styles.name} data-part="name">
              {name}
            </span>
            {logo.tagline && (
              <span className={styles.tagline} data-part="tagline">
                {logo.tagline}
              </span>
            )}
          </div>

          <button
            ref={startRef}
            type="button"
            className={styles.start}
            data-part="start"
            tabIndex={menu ? -1 : 0}
            aria-hidden={menu || undefined}
            onClick={() => {
              take();
              if (!menu) act('any');
            }}
          >
            <span key={pressed} className={styles.prompt} data-part="prompt">
              {prompt}
            </span>
          </button>

          <ul
            className={styles.menu}
            data-part="menu"
            role="menu"
            aria-label="Main menu"
            aria-hidden={menu ? undefined : true}
            style={{ '--at': current, '--w': widths[current] ?? 0 } as CSSProperties}
          >
            {items.map((item, index) => (
              <li key={item.id} role="none" style={{ '--i': index } as CSSProperties}>
                <button
                  ref={(element) => {
                    itemRefs.current[index] = element;
                  }}
                  type="button"
                  role="menuitem"
                  className={styles.item}
                  data-part="item"
                  data-on={index === current ? '' : undefined}
                  tabIndex={menu && index === current ? 0 : -1}
                  onPointerMove={(event) => {
                    if (event.pointerType !== 'mouse' || !menu || index === current) return;
                    take();
                    select(index);
                  }}
                  onClick={() => {
                    take();
                    if (!menu) return;
                    if (index !== current) select(index);
                    else act('confirm');
                  }}
                >
                  {item.label}
                  {index === current && chosen > 0 && (
                    <span key={chosen} className={styles.flash} aria-hidden="true" />
                  )}
                </button>
              </li>
            ))}
            <li role="none" className={styles.track} aria-hidden="true">
              <span key={bump.n} className={styles.nudge} data-dir={bump.n ? bump.dir : undefined}>
                <span
                  key={chosen}
                  className={styles.pointer}
                  data-part="pointer"
                  data-dart={chosen ? '' : undefined}
                >
                  <svg viewBox="0 0 16 6" className={styles.pointerArt}>
                    <path d="M0 3.4C2.6 1.6 4.4 0.6 6.2 0.3L5.2 1.9C8.6 1 12.6 1.3 16 2.6C13.2 4.2 9.4 5.4 4.6 5.3C3 5.2 1.4 4.5 0 3.4Z" />
                  </svg>
                </span>
              </span>
            </li>
          </ul>

          <p className={styles.version} data-part="version">
            {version.map((line) => (
              <span key={line}>{line}</span>
            ))}
          </p>
        </div>
      </div>
      <p className={styles.live} aria-live="polite">
        {said}
      </p>
    </div>
  );
}
```

### title-screen/title-screen.module.css

```css
/* Title screen (title-screen.tsx): a whole game screen on a device of its own. The bezel is navy
   with a cyan rim and small cyan ornaments; inside it, the painted landscape (art.tsx), the
   wordmark, the intro prompt, the menu with its pointer glyph, and the version lines.

   Every size is a multiple of --u, one design unit (1 px at full size) of a 960 × 540 screen
   (--sw × --sh) inside a --bz bezel, or 392 × 640 on a narrow stage. --u comes from the stage's
   width by a container query unit, so the device scales as one drawing; --title-unit fixes it.
   On the narrow screen the painting keeps its height and is cropped around the traveller.

   Colours: the screen keeps its colours on any page, as a game screen does; only the bezel's edge,
   its shadow and the focus ring follow the scheme, through light-dark().

   Motion: the painting's layers pan by translate with their depth (--k), the clouds, mist, birds,
   halo and rays have their own slow loops; the wordmark glides by translate and scale between the
   intro and the menu; the menu items slide in by keyframes, staggered; the pointer slides by
   translate with an overshoot. Off screen everything pauses; reduced motion stops it all. */

.root {
  /* The screen. */
  --ink: #f6f3e9;
  --ink-dim: rgb(226 230 228 / 0.9);
  --mark: #f3eedf;
  --logo: #efe8d6;
  --logo-shadow: rgb(54 34 18 / 0.42);
  /* The device. */
  --bezel: #0f1d33;
  --bezel-hi: #1c3254;
  --bezel-lo: #08111f;
  --rim: rgb(124 233 255 / 0.62);
  --glow: rgb(64 208 255 / 0.42);
  --stud: #8beeff;
  /* On the page: focus, the device's edge and its shadow. */
  --focus: #1b4fd8;
  --edge: rgb(0 0 0 / 0);
  --drop: rgb(10 18 34 / 0.26);
  position: relative;
  container-type: inline-size;
  isolation: isolate;
  display: grid;
  justify-items: center;
  align-content: center;
  box-sizing: border-box;
  width: 100%;
  padding: 24px 16px;
  font-family:
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --focus: light-dark(#1b4fd8, #9cc3ff);
    --edge: light-dark(rgb(0 0 0 / 0), rgb(255 255 255 / 0.12));
    --drop: light-dark(rgb(10 18 34 / 0.26), rgb(0 0 0 / 0.55));
  }
}
.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 device ---- */

.device {
  --sw: 960;
  --sh: 540;
  --bz: 16;
  --u: var(--title-unit, min(1px, calc(100cqi / (var(--sw) + 2 * var(--bz)))));
  position: relative;
  padding: calc(var(--bz) * var(--u));
  border-radius: calc(24 * var(--u));
  background:
    linear-gradient(160deg, var(--bezel-hi), var(--bezel) 42%, var(--bezel-lo)) padding-box,
    var(--bezel);
  box-shadow:
    inset 0 calc(1 * var(--u)) 0 rgb(170 220 255 / 0.2),
    inset 0 calc(-1 * var(--u)) 0 rgb(0 0 0 / 0.5),
    0 0 0 1px var(--edge),
    0 calc(16 * var(--u)) calc(44 * var(--u)) var(--drop);
}
@container (max-width: 599px) {
  .device {
    --sw: 392;
    --sh: 640;
    --bz: 10;
  }
}
/* The cyan rim around the glass. */
.device::before {
  content: '';
  position: absolute;
  inset: calc((var(--bz) - 5) * var(--u));
  border: calc(1 * var(--u)) solid var(--rim);
  border-radius: calc(13 * var(--u));
  box-shadow:
    0 0 calc(6 * var(--u)) var(--glow),
    inset 0 0 calc(6 * var(--u)) var(--glow);
  pointer-events: none;
  transition:
    border-color 300ms ease,
    box-shadow 300ms ease;
}
.root[data-phase='menu'] .device::before {
  animation: rim 900ms ease-out;
}

/* Ornaments: diamond studs at the corners, a ruled diamond at the top, a lit slot at the foot. */
.ornaments {
  position: absolute;
  inset: 0;
  pointer-events: none;
}
.ornaments i {
  position: absolute;
  width: calc(6 * var(--u));
  height: calc(6 * var(--u));
  border: calc(1 * var(--u)) solid var(--stud);
  background: var(--bezel-lo);
  box-shadow: 0 0 calc(5 * var(--u)) var(--glow);
  rotate: 45deg;
  translate: -50% -50%;
}
.ornaments i[data-at='tl'] {
  left: calc(var(--bz) / 2 * var(--u) + 2 * var(--u));
  top: calc(var(--bz) / 2 * var(--u) + 2 * var(--u));
}
.ornaments i[data-at='tr'] {
  left: calc(100% - var(--bz) / 2 * var(--u) - 2 * var(--u));
  top: calc(var(--bz) / 2 * var(--u) + 2 * var(--u));
}
.ornaments i[data-at='bl'] {
  left: calc(var(--bz) / 2 * var(--u) + 2 * var(--u));
  top: calc(100% - var(--bz) / 2 * var(--u) - 2 * var(--u));
}
.ornaments i[data-at='br'] {
  left: calc(100% - var(--bz) / 2 * var(--u) - 2 * var(--u));
  top: calc(100% - var(--bz) / 2 * var(--u) - 2 * var(--u));
}
.ornaments b {
  position: absolute;
  left: 50%;
  translate: -50% -50%;
}
.ornaments b[data-at='top'] {
  top: calc(var(--bz) / 2 * var(--u) - 0.5 * var(--u));
  width: calc(64 * var(--u));
  height: calc(1 * var(--u));
  background: linear-gradient(90deg, transparent, var(--rim) 30%, var(--rim) 70%, transparent);
}
.ornaments b[data-at='top']::after {
  content: '';
  position: absolute;
  left: 50%;
  top: 50%;
  width: calc(5 * var(--u));
  height: calc(5 * var(--u));
  background: var(--stud);
  box-shadow: 0 0 calc(5 * var(--u)) var(--glow);
  rotate: 45deg;
  translate: -50% -50%;
}
.ornaments b[data-at='bottom'] {
  top: calc(100% - var(--bz) / 2 * var(--u) + 0.5 * var(--u));
  width: calc(30 * var(--u));
  height: calc(4 * var(--u));
  border-radius: 999px;
  background: var(--bezel-lo);
  box-shadow:
    inset 0 0 0 calc(1 * var(--u)) var(--rim),
    0 0 calc(5 * var(--u)) var(--glow);
}

/* ---- The screen ---- */

.screen {
  /* Layout, in units: the wordmark's centre and scale on the menu and on the intro, the menu's
     first row and pitch, the right margin and the prompt's line. */
  --logo-x: 824;
  --logo-y: 102;
  --logo-s: 1;
  --intro-x: 480;
  --intro-y: 198;
  --intro-s: 1.45;
  --menu-y: 256;
  --pitch: 30;
  --item: 13.5;
  --margin: 47;
  --prompt-y: 432;
  /* The painting: its scale and its left crop. */
  --a: var(--u);
  --crop: 0;
  position: relative;
  width: calc(var(--sw) * var(--u));
  height: calc(var(--sh) * var(--u));
  overflow: hidden;
  border-radius: calc(8 * var(--u));
  background: #1f3358;
  color: var(--ink);
  line-height: 1;
  user-select: none;
  -webkit-user-select: none;
}
@container (max-width: 599px) {
  .screen {
    --logo-x: 196;
    --logo-y: 104;
    --logo-s: 0.92;
    --intro-x: 196;
    --intro-y: 196;
    --intro-s: 1.12;
    --menu-y: 452;
    --pitch: 33;
    --item: 15;
    --margin: 24;
    --prompt-y: 548;
    --a: calc(var(--u) * 640 / 540);
    --crop: 246;
  }
}

.paint {
  position: absolute;
  inset: 0;
  animation: breath 48s ease-in-out infinite alternate;
}
.art {
  position: absolute;
  top: 0;
  left: calc(var(--crop) * -1 * var(--a));
  display: block;
  width: calc(960 * var(--a));
  height: calc(540 * var(--a));
}
.art [data-layer] {
  animation: pan 28s ease-in-out infinite alternate;
}
.art [data-layer='clouds-far'] {
  animation: clouds 64s ease-in-out infinite alternate;
}
.art [data-layer='mist'] {
  animation: mist 38s ease-in-out infinite alternate;
}
.art [data-layer='halo'] {
  animation: halo 7s ease-in-out infinite alternate;
}
.art [data-layer='rays'] {
  animation: rays 11s ease-in-out infinite alternate;
}
.art [data-layer='birds'] {
  animation: birds 34s linear infinite;
}

/* Darker toward the menu, the corner lines and the foot, for the words to read; a soft vignette. */
.shade {
  position: absolute;
  inset: 0;
  background:
    linear-gradient(270deg, rgb(12 20 38 / 0.42), rgb(12 20 38 / 0) 38%),
    linear-gradient(0deg, rgb(10 16 30 / 0.4), rgb(10 16 30 / 0) 30%),
    radial-gradient(ellipse 80% 80% at 50% 45%, rgb(0 0 0 / 0) 60%, rgb(6 10 22 / 0.4));
  pointer-events: none;
}

/* ---- The wordmark ---- */

.logo {
  position: absolute;
  left: calc((var(--logo-x) - 125) * var(--u));
  top: calc((var(--logo-y) - 60) * var(--u));
  display: grid;
  justify-items: center;
  align-content: center;
  width: calc(250 * var(--u));
  height: calc(120 * var(--u));
  color: var(--logo);
  font-family:
    'Iowan Old Style', 'Palatino Linotype', Palatino, 'Book Antiqua', Georgia, 'Times New Roman',
    serif;
  text-shadow:
    0 calc(1 * var(--u)) 0 var(--logo-shadow),
    0 0 calc(14 * var(--u)) rgb(40 22 8 / 0.3);
  transform-origin: 50% 50%;
  translate: 0 0;
  scale: var(--logo-s);
  transition:
    translate 760ms cubic-bezier(0.2, 0.8, 0.2, 1),
    scale 760ms cubic-bezier(0.2, 0.8, 0.2, 1);
  pointer-events: none;
}
.root[data-phase='intro'] .logo {
  translate: calc((var(--intro-x) - var(--logo-x)) * var(--u))
    calc((var(--intro-y) - var(--logo-y)) * var(--u));
  scale: var(--intro-s);
}
.kicker {
  display: flex;
  align-items: center;
  gap: calc(7 * var(--u));
  font-size: calc(10.5 * var(--u));
  font-weight: 600;
  letter-spacing: 0.26em;
  text-transform: uppercase;
  white-space: nowrap;
}
.kicker i {
  width: calc(18 * var(--u));
  height: calc(1 * var(--u));
  background: currentColor;
  box-shadow: 0 calc(1 * var(--u)) 0 var(--logo-shadow);
}
.name {
  margin-top: calc(3 * var(--u));
  font-size: calc(min(50, 340 / var(--len)) * var(--u));
  font-weight: 700;
  letter-spacing: 0.05em;
  line-height: 1;
  text-transform: uppercase;
  white-space: nowrap;
}
/* A thin rule with a centre diamond between the name and the tagline. */
.tagline {
  position: relative;
  margin-top: calc(5 * var(--u));
  padding-top: calc(6 * var(--u));
  font-size: calc(12 * var(--u));
  font-style: italic;
  letter-spacing: 0.06em;
  white-space: nowrap;
}
.tagline::before {
  content: '';
  position: absolute;
  left: 50%;
  top: 0;
  width: calc(120 * var(--u));
  height: calc(1 * var(--u));
  translate: -50% 0;
  background: linear-gradient(90deg, transparent, currentColor 25%, currentColor 75%, transparent);
}
.tagline::after {
  content: '';
  position: absolute;
  left: 50%;
  top: calc(0.5 * var(--u));
  width: calc(5 * var(--u));
  height: calc(5 * var(--u));
  background: currentColor;
  rotate: 45deg;
  translate: -50% -50%;
}

/* ---- The intro ---- */

.start {
  position: absolute;
  inset: 0;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  cursor: pointer;
  outline: none;
  touch-action: manipulation;
}
.root[data-phase='menu'] .start {
  pointer-events: none;
  cursor: default;
}
.prompt {
  position: absolute;
  left: 0;
  right: 0;
  top: calc((var(--prompt-y) - 10) * var(--u));
  font-size: calc(15 * var(--u));
  font-style: italic;
  line-height: calc(20 * var(--u));
  letter-spacing: 0.02em;
  text-align: center;
  text-shadow:
    0 0 calc(6 * var(--u)) rgb(0 0 0 / 0.55),
    0 calc(1 * var(--u)) calc(2 * var(--u)) rgb(0 0 0 / 0.5);
  animation: pulse 1700ms ease-in-out infinite alternate;
}
.root[data-phase='menu'] .prompt {
  animation: press 420ms ease-out forwards;
}

/* ---- The menu ---- */

.menu {
  position: absolute;
  right: calc(var(--margin) * var(--u));
  top: calc((var(--menu-y) - var(--pitch) / 2) * var(--u));
  margin: 0;
  padding: 0;
  list-style: none;
  font-style: italic;
  visibility: hidden;
}
.root[data-phase='menu'] .menu {
  visibility: visible;
}
.menu li {
  display: flex;
  justify-content: flex-end;
  align-items: center;
  height: calc(var(--pitch) * var(--u));
  opacity: 0;
  translate: calc(18 * var(--u)) 0;
}
.root[data-phase='menu'] .menu li {
  opacity: 1;
  translate: 0 0;
  animation: slide-in 460ms cubic-bezier(0.2, 0.9, 0.3, 1) both;
  animation-delay: calc(var(--i, 0) * 60ms + 260ms);
}
.item {
  position: relative;
  margin: 0;
  padding: calc(2 * var(--u)) 0;
  border: 0;
  border-radius: calc(3 * var(--u));
  background: none;
  color: var(--ink-dim);
  font: inherit;
  font-size: calc(var(--item) * var(--u));
  font-weight: 400;
  line-height: 1.15;
  white-space: nowrap;
  text-shadow:
    0 0 calc(5 * var(--u)) rgb(6 12 26 / 0.55),
    0 calc(1 * var(--u)) calc(1 * var(--u)) rgb(6 12 26 / 0.4);
  cursor: pointer;
  outline: none;
  transition:
    color 140ms ease,
    text-shadow 140ms ease;
}
.item[data-on] {
  color: var(--ink);
  text-shadow:
    0 0 calc(6 * var(--u)) rgb(255 246 220 / 0.32),
    0 0 calc(5 * var(--u)) rgb(6 12 26 / 0.55),
    0 calc(1 * var(--u)) calc(1 * var(--u)) rgb(6 12 26 / 0.4);
}
.flash {
  position: absolute;
  inset: calc(-4 * var(--u)) calc(-10 * var(--u));
  border-radius: 999px;
  background: radial-gradient(closest-side, rgb(255 248 224 / 0.5), rgb(255 248 224 / 0));
  opacity: 0;
  animation: flash 520ms ease-out;
  pointer-events: none;
}

/* The pointer: one glyph, slid to the selected row and to just before its words. */
.menu li.track {
  position: absolute;
  top: 0;
  right: 0;
  width: 0;
  height: calc(var(--pitch) * var(--u));
  translate: 0 calc(var(--at) * var(--pitch) * var(--u));
  transition: translate 190ms cubic-bezier(0.3, 1.5, 0.5, 1);
  animation: none;
}
.root[data-phase='menu'] .menu li.track {
  translate: 0 calc(var(--at) * var(--pitch) * var(--u));
  animation: fade-in 300ms 300ms ease both;
}
.nudge {
  position: absolute;
  inset: 0;
}
.nudge[data-dir='up'] {
  animation: nudge-up 220ms ease-out;
}
.nudge[data-dir='down'] {
  animation: nudge-down 220ms ease-out;
}
.pointer {
  position: absolute;
  top: 50%;
  right: calc(var(--w) * 1px + 12 * var(--u));
  width: calc(16 * var(--u));
  height: calc(6 * var(--u));
  margin-top: calc(-3.5 * var(--u));
  transition: right 190ms cubic-bezier(0.3, 1.3, 0.5, 1);
}
.pointer[data-dart] {
  animation: dart 380ms ease-out;
}
.pointerArt {
  display: block;
  width: 100%;
  height: 100%;
  fill: var(--mark);
  overflow: visible;
  filter: drop-shadow(0 0 calc(2 * var(--u)) rgb(6 12 26 / 0.6));
}

/* ---- The corner lines ---- */

.version {
  position: absolute;
  right: calc(var(--margin) * var(--u));
  bottom: calc(20 * var(--u));
  display: grid;
  justify-items: end;
  margin: 0;
  color: var(--ink);
  font-size: calc(9 * var(--u));
  font-style: italic;
  line-height: calc(15.5 * var(--u));
  text-shadow: 0 0 calc(4 * var(--u)) rgb(6 12 26 / 0.6);
  opacity: 0;
  transition: opacity 300ms ease;
}
.root[data-phase='menu'] .version {
  opacity: 0.92;
  transition: opacity 500ms 500ms ease;
}

/* ---- Focus, live region ---- */

.item:focus-visible {
  outline: calc(1 * var(--u)) solid rgb(255 255 255 / 0.45);
  outline-offset: calc(3 * var(--u));
}
.start:focus-visible .prompt {
  text-decoration: underline;
  text-underline-offset: calc(5 * var(--u));
  text-decoration-thickness: calc(1 * var(--u));
}
.root:has(:focus-visible) .device {
  box-shadow:
    inset 0 calc(1 * var(--u)) 0 rgb(170 220 255 / 0.2),
    inset 0 calc(-1 * var(--u)) 0 rgb(0 0 0 / 0.5),
    0 0 0 2px var(--focus),
    0 calc(16 * var(--u)) calc(44 * var(--u)) var(--drop);
}
.live {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* ---- Motion ---- */

.root[data-paused] *,
.root[data-paused] *::before {
  animation-play-state: paused !important;
}
.root[data-still] .paint,
.root[data-still] .art [data-layer] {
  animation: none;
}
.root[data-reduced] *,
.root[data-reduced] *::before,
.root[data-reduced] *::after {
  animation: none !important;
  transition-duration: 0s !important;
  transition-delay: 0s !important;
}
.root[data-reduced][data-phase='menu'] .prompt {
  opacity: 0;
}

@keyframes pan {
  from {
    translate: calc(var(--k, 0) * -16px) 0;
  }
  to {
    translate: calc(var(--k, 0) * 16px) calc(var(--k, 0) * -3px);
  }
}
@keyframes clouds {
  from {
    translate: -34px 0;
  }
  to {
    translate: 40px -4px;
  }
}
@keyframes mist {
  from {
    translate: 26px 0;
  }
  to {
    translate: -30px 2px;
  }
}
@keyframes halo {
  from {
    opacity: 0.82;
  }
  to {
    opacity: 1;
  }
}
@keyframes rays {
  from {
    opacity: 0.35;
  }
  to {
    opacity: 0.9;
  }
}
@keyframes birds {
  0% {
    translate: 120px 30px;
    opacity: 0;
  }
  8% {
    opacity: 1;
  }
  85% {
    opacity: 1;
  }
  100% {
    translate: -320px -70px;
    opacity: 0;
  }
}
@keyframes breath {
  from {
    scale: 1;
  }
  to {
    scale: 1.035;
  }
}
@keyframes pulse {
  from {
    opacity: 0.4;
  }
  to {
    opacity: 1;
  }
}
@keyframes press {
  0% {
    opacity: 1;
    scale: 1;
    color: #fff;
  }
  30% {
    opacity: 1;
    scale: 1.08;
    text-shadow: 0 0 calc(14 * var(--u)) rgb(255 248 220 / 0.9);
  }
  100% {
    opacity: 0;
    scale: 1.12;
  }
}
@keyframes slide-in {
  from {
    opacity: 0;
    translate: calc(22 * var(--u)) 0;
  }
}
@keyframes fade-in {
  from {
    opacity: 0;
  }
}
@keyframes flash {
  from {
    opacity: 1;
  }
}
@keyframes dart {
  30% {
    translate: calc(5 * var(--u)) 0;
  }
}
@keyframes nudge-up {
  40% {
    translate: 0 calc(-4 * var(--u));
  }
}
@keyframes nudge-down {
  40% {
    translate: 0 calc(4 * var(--u));
  }
}
@keyframes rim {
  25% {
    border-color: rgb(200 248 255 / 0.95);
    box-shadow:
      0 0 calc(14 * var(--u)) rgb(90 220 255 / 0.8),
      inset 0 0 calc(10 * var(--u)) rgb(90 220 255 / 0.6);
  }
}
```

### title-screen/art.tsx

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

/*
 * The title screen's painting: a lone traveller, seen from behind, on a rock at a cliff edge over
 * a wide valley at sunset. One SVG in a 960 × 540 drawing, built from flat shapes and gradients in
 * layers, back to front: the sky and the sun's halo, two banks of cloud, three mountain ranges
 * hazier with distance, mist in the valley, the near hills with a river, the cliff and the
 * traveller, the foreground grass, and a few birds.
 *
 * Each layer is a <g> with a depth (--k): the CSS pans them together, far ones barely and near ones
 * most, which reads as a slow camera drift. Clouds and birds have their own slower loops. Ridges
 * are midpoint-displacement lines from a fixed seed, so the server and the browser draw the same
 * mountains. Every layer is drawn wider than the frame, so a pan never shows an edge.
 */

import type { CSSProperties } from 'react';

/** A small seeded random: the same numbers on every render. */
function seeded(seed: number) {
  let s = seed >>> 0;
  return () => {
    s = (s * 1664525 + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

/** A ridge line from x0 to x1 around height y: midpoint displacement, closed down to `base`. */
function ridge(
  seed: number,
  x0: number,
  x1: number,
  y: number,
  rough: number,
  base: number,
  peaks: [number, number][] = [],
) {
  const random = seeded(seed);
  let points: [number, number][] = [
    [x0, y],
    [x1, y],
  ];
  let spread = rough;
  for (let pass = 0; pass < 7; pass++) {
    const next: [number, number][] = [];
    for (let i = 0; i < points.length - 1; i++) {
      const [ax, ay] = points[i];
      const [bx, by] = points[i + 1];
      next.push([ax, ay], [(ax + bx) / 2, (ay + by) / 2 + (random() - 0.5) * spread]);
    }
    next.push(points[points.length - 1]);
    points = next;
    spread *= 0.55;
  }
  // Raise named peaks: a smooth bump of [x, height] spread over ±90 units.
  points = points.map(([x, py]) => {
    let lift = 0;
    for (const [px, h] of peaks) lift += h * Math.max(0, 1 - Math.abs(x - px) / 90) ** 1.6;
    return [x, py - lift];
  });
  return (
    `M${x0} ${base}` +
    points.map(([x, py]) => `L${x.toFixed(1)} ${py.toFixed(1)}`).join('') +
    `L${x1} ${base}Z`
  );
}

/**
 * A cloud's outline: a billowing top of arcs over a gently curved base, from (x, y) w wide and up
 * to h tall. `lean` tips the heap toward one side.
 */
function billow(seed: number, x: number, y: number, w: number, h: number, n = 9, lean = 0) {
  const random = seeded(seed);
  let d = `M${x} ${y}`;
  let [px, py] = [x, y];
  for (let i = 1; i <= n; i++) {
    const t = i / n;
    const nx = x + w * t;
    const dome = Math.sin(Math.PI * Math.min(1, Math.max(0, t + lean * (t - 0.5))));
    const ny = i === n ? y : y - h * dome * (0.7 + 0.3 * random());
    const r = (Math.hypot(nx - px, ny - py) / 2) * (1.02 + random() * 0.5);
    d += `A${r.toFixed(1)} ${r.toFixed(1)} 0 0 1 ${nx.toFixed(1)} ${ny.toFixed(1)}`;
    [px, py] = [nx, ny];
  }
  return d + `Q${x + w / 2} ${y + h * 0.1} ${x} ${y}Z`;
}

/** A cumulus in three passes: its shade, its body, and the lit heaps toward the sun. */
function Cloud({
  seed,
  x,
  y,
  w,
  h,
  shade,
  body,
  light,
  toward = 1,
}: {
  seed: number;
  x: number;
  y: number;
  w: number;
  h: number;
  shade: string;
  body: string;
  light: string;
  /** Which side the sun lights: 1 the right, -1 the left. */
  toward?: 1 | -1;
}) {
  return (
    <>
      <path d={billow(seed, x - 4, y + 5, w + 8, h * 0.92, 11)} fill={shade} />
      <path d={billow(seed + 1, x, y, w, h, 10, -0.15 * toward)} fill={body} />
      <path
        d={billow(
          seed + 2,
          x + (toward > 0 ? w * 0.34 : w * 0.06),
          y - h * 0.22,
          w * 0.6,
          h * 0.72,
          7,
          0.2 * toward,
        )}
        fill={light}
      />
      <path
        d={billow(
          seed + 3,
          x + (toward > 0 ? w * 0.56 : w * 0.12),
          y - h * 0.45,
          w * 0.3,
          h * 0.42,
          5,
        )}
        fill={light}
        opacity="0.8"
      />
    </>
  );
}

/** A long thin streak of high cloud. */
function streak(x: number, y: number, w: number, h: number, tilt = 0) {
  return `M${x} ${y}C${x + w * 0.3} ${y - h + tilt} ${x + w * 0.7} ${y - h * 0.6 - tilt} ${x + w} ${y - tilt}C${x + w * 0.7} ${y + h * 0.3 - tilt} ${x + w * 0.3} ${y + h * 0.5} ${x} ${y}Z`;
}

const depth = (k: number) => ({ '--k': k }) as CSSProperties;

/** Grass along a line: clumps of thin curved blades, fanned and uneven, as one path. */
function tufts(seed: number, x0: number, x1: number, y: (x: number) => number, tall: number) {
  const random = seeded(seed);
  let d = '';
  for (let x = x0; x < x1; x += 5 + random() * 11) {
    if (random() < 0.18) continue;
    const base = y(x);
    const clump = 2 + Math.floor(random() * 4);
    const size = tall * (0.5 + random() * 0.7);
    for (let i = 0; i < clump; i++) {
      const bx = x + (random() - 0.5) * 6;
      const h = size * (0.55 + random() * 0.5);
      const lean = ((i / Math.max(clump - 1, 1)) * 2 - 1) * h * 0.45 + (random() - 0.5) * 3;
      const f = (n: number) => n.toFixed(1);
      d += `M${f(bx - 1.2)} ${f(base)}Q${f(bx + lean * 0.3)} ${f(base - h * 0.6)} ${f(bx + lean)} ${f(base - h)}Q${f(bx + lean * 0.25 + 0.5)} ${f(base - h * 0.5)} ${f(bx + 1.3)} ${f(base)}Z`;
    }
  }
  return d;
}

/** A line of small round-topped trees, as one path. */
function grove(seed: number, x0: number, x1: number, y: (x: number) => number, size: number) {
  const random = seeded(seed);
  let d = '';
  for (let x = x0; x < x1; x += size * (0.5 + random() * 0.9)) {
    if (random() < 0.25) continue;
    const w = size * (0.35 + random() * 0.3);
    const h = size * (0.8 + random() * 0.8);
    const base = y(x) + random() * 4;
    d += `M${(x - w).toFixed(1)} ${base.toFixed(1)}Q${(x - w).toFixed(1)} ${(base - h * 0.75).toFixed(1)} ${x.toFixed(1)} ${(base - h).toFixed(1)}Q${(x + w).toFixed(1)} ${(base - h * 0.75).toFixed(1)} ${(x + w).toFixed(1)} ${base.toFixed(1)}Z`;
  }
  return d;
}

const GROVE_FAR = grove(61, 540, 1040, (x) => 432 - (x - 540) * 0.02, 9);
const GROVE_NEAR = grove(67, 560, 1040, (x) => 470 - (x - 560) * 0.03, 15);
const GROVE_LEFT = grove(69, -80, 190, (x) => 452 + x * 0.02, 13);

const FAR = ridge(11, -80, 1040, 258, 46, 360, [
  [610, 34],
  [150, 18],
  [860, 22],
]);
const MID = ridge(29, -80, 1040, 300, 60, 400, [
  [90, 40],
  [740, 30],
  [940, 46],
]);
const NEAR = ridge(53, -100, 1060, 360, 56, 470, [
  [-10, 50],
  [860, 44],
]);
const VALLEY = ridge(71, -100, 1060, 408, 24, 540, [[980, 20]]);

const CLIFF_GRASS = tufts(5, 236, 456, (x) => 398 + Math.abs(x - 345) * 0.05, 12);
const CLIFF_LIGHT = tufts(6, 380, 452, (x) => 400 + (x - 380) * 0.08, 9);
const FRONT_GRASS = tufts(8, 520, 1040, (x) => 520 - (x - 520) * 0.07, 22);
const LEFT_GRASS = tufts(13, -60, 210, (x) => 512 - (210 - x) * 0.05, 20);

export function TitleArt({ id, className }: { id: string; className?: string }) {
  const g = (name: string) => `${id}-${name}`;
  const url = (name: string) => `url(#${g(name)})`;
  return (
    <svg
      className={className}
      viewBox="0 0 960 540"
      preserveAspectRatio="none"
      aria-hidden="true"
      focusable="false"
      data-part="art"
    >
      <defs>
        <linearGradient id={g('sky')} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#2e4a78" />
          <stop offset="0.28" stopColor="#71809f" />
          <stop offset="0.48" stopColor="#dcaa78" />
          <stop offset="0.6" stopColor="#f7d59c" />
          <stop offset="0.75" stopColor="#fbe7c2" />
        </linearGradient>
        <radialGradient id={g('sun')} cx="590" cy="196" r="420" gradientUnits="userSpaceOnUse">
          <stop offset="0" stopColor="#fffbea" />
          <stop offset="0.08" stopColor="#fff2c8" />
          <stop offset="0.3" stopColor="#ffd58e" stopOpacity="0.7" />
          <stop offset="1" stopColor="#f6b46a" stopOpacity="0" />
        </radialGradient>
        <radialGradient id={g('dusk')} cx="40" cy="0" r="560" gradientUnits="userSpaceOnUse">
          <stop offset="0" stopColor="#14223f" stopOpacity="0.6" />
          <stop offset="1" stopColor="#14223f" stopOpacity="0" />
        </radialGradient>
        <linearGradient
          id={g('far')}
          x1="0"
          y1="200"
          x2="0"
          y2="360"
          gradientUnits="userSpaceOnUse"
        >
          <stop offset="0" stopColor="#b9a7ad" />
          <stop offset="1" stopColor="#8e9ab4" />
        </linearGradient>
        <linearGradient
          id={g('mid')}
          x1="0"
          y1="230"
          x2="0"
          y2="400"
          gradientUnits="userSpaceOnUse"
        >
          <stop offset="0" stopColor="#7d7f9c" />
          <stop offset="0.5" stopColor="#5d7096" />
          <stop offset="1" stopColor="#7d93b4" />
        </linearGradient>
        <linearGradient
          id={g('near')}
          x1="0"
          y1="300"
          x2="0"
          y2="470"
          gradientUnits="userSpaceOnUse"
        >
          <stop offset="0" stopColor="#6079a0" />
          <stop offset="1" stopColor="#3a5579" />
        </linearGradient>
        <linearGradient
          id={g('valley')}
          x1="0"
          y1="390"
          x2="0"
          y2="540"
          gradientUnits="userSpaceOnUse"
        >
          <stop offset="0" stopColor="#56739a" />
          <stop offset="0.5" stopColor="#3a5778" />
          <stop offset="1" stopColor="#263d5a" />
        </linearGradient>
        <linearGradient id={g('haze')} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#f3d6b0" stopOpacity="0" />
          <stop offset="1" stopColor="#f0d2ad" stopOpacity="0.75" />
        </linearGradient>
        <linearGradient id={g('mist')} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#c9d3e2" stopOpacity="0" />
          <stop offset="0.6" stopColor="#c9d3e2" stopOpacity="0.55" />
          <stop offset="1" stopColor="#c9d3e2" stopOpacity="0" />
        </linearGradient>
        <linearGradient
          id={g('rock')}
          x1="0"
          y1="390"
          x2="0"
          y2="540"
          gradientUnits="userSpaceOnUse"
        >
          <stop offset="0" stopColor="#4d4c55" />
          <stop offset="1" stopColor="#24242c" />
        </linearGradient>
        <linearGradient id={g('rays')} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#fff3cf" stopOpacity="0.45" />
          <stop offset="1" stopColor="#fff3cf" stopOpacity="0" />
        </linearGradient>
        <linearGradient
          id={g('ground')}
          x1="0"
          y1="470"
          x2="0"
          y2="540"
          gradientUnits="userSpaceOnUse"
        >
          <stop offset="0" stopColor="#4e5a2c" />
          <stop offset="1" stopColor="#262c18" />
        </linearGradient>
      </defs>

      {/* Sky, halo and the dusk in the top left corner. */}
      <rect x="-100" y="-20" width="1160" height="580" fill={url('sky')} />
      <g data-layer="halo">
        <rect x="-100" y="-20" width="1160" height="580" fill={url('sun')} />
      </g>
      <rect x="-100" y="-20" width="1160" height="580" fill={url('dusk')} />

      {/* Light shafts fanning down from the sun. */}
      <g data-layer="rays" fill={url('rays')}>
        <path d="M590 214L330 540L420 540Z" />
        <path d="M590 214L520 540L580 540Z" />
        <path d="M590 214L700 540L790 540Z" />
        <path d="M590 214L900 520L960 470Z" />
      </g>

      {/* High clouds: a dark bank in the top left, golden heaps around the sun. */}
      <g data-layer="clouds-far" style={depth(0.1)}>
        {/* A dark sweep across the top left, and pale streaks of high cloud. */}
        <path
          d="M-100 -20H560C520 20 460 30 400 44C330 60 300 96 230 104C160 112 120 150 40 160C0 166 -60 180 -100 186Z"
          fill="#22345a"
          opacity="0.75"
        />
        <g fill="#5d6e95" opacity="0.7">
          <path d={streak(-40, 96, 300, 22, 4)} />
          <path d={streak(120, 60, 260, 16, -3)} />
          <path d={streak(20, 150, 220, 14, 2)} />
        </g>
        <g fill="#e8b98a" opacity="0.55">
          <path d={streak(300, 120, 200, 12, 2)} />
          <path d={streak(380, 86, 160, 10, -2)} />
          <path d={streak(640, 248, 260, 10, 3)} />
        </g>
        <Cloud
          seed={3}
          x={210}
          y={176}
          w={190}
          h={70}
          shade="#8a6a6b"
          body="#d39568"
          light="#f6c989"
        />
        <Cloud
          seed={7}
          x={-60}
          y={262}
          w={250}
          h={66}
          shade="#5b6688"
          body="#8f8fa6"
          light="#d9c0a8"
        />
        <Cloud
          seed={17}
          x={700}
          y={168}
          w={300}
          h={150}
          shade="#8d6a68"
          body="#d9a06f"
          light="#fbd9a1"
          toward={-1}
        />
        <Cloud
          seed={23}
          x={650}
          y={252}
          w={200}
          h={64}
          shade="#a07c70"
          body="#e7b47f"
          light="#fde6b6"
          toward={-1}
        />
      </g>

      {/* Mountains, far to near, each hazier toward its foot. */}
      <g data-layer="far" style={depth(0.15)}>
        <path d={FAR} fill={url('far')} />
        <path d={FAR} fill="none" stroke="#ffe7c2" strokeWidth="1.2" opacity="0.55" />
        <rect x="-100" y="236" width="1160" height="124" fill={url('haze')} />
      </g>
      <g data-layer="mid" style={depth(0.3)}>
        <path d={MID} fill={url('mid')} />
        <path d={MID} fill="none" stroke="#f8d9b0" strokeWidth="1.4" opacity="0.5" />
        <rect x="-100" y="300" width="1160" height="100" fill={url('haze')} opacity="0.55" />
      </g>
      <g data-layer="mist" style={depth(0.38)}>
        <rect x="-100" y="330" width="1160" height="70" fill={url('mist')} />
        <g fill="#c3cddc" opacity="0.8">
          <path d={billow(41, 10, 360, 120, 22, 7)} />
          <path d={billow(43, 540, 366, 110, 18, 6)} />
          <path d={billow(47, 820, 352, 150, 20, 7)} />
        </g>
      </g>
      <g data-layer="near" style={depth(0.5)}>
        <path d={NEAR} fill={url('near')} />
        <path d={NEAR} fill="none" stroke="#d9c4a8" strokeWidth="1.4" opacity="0.4" />
        <path d={VALLEY} fill={url('valley')} />
        {/* Sunlit fields, the river, and the groves on the valley floor. */}
        <path
          d="M590 560C615 520 650 500 690 484C730 468 770 452 820 440C860 431 900 426 950 424C905 430 868 436 828 446C782 458 744 474 708 492C672 510 646 532 632 560Z"
          fill="#f1d3a0"
          opacity="0.8"
        />
        <path d={GROVE_FAR} fill="#2c4462" />
        <path d={GROVE_LEFT} fill="#22364f" />
        <rect x="-100" y="400" width="1160" height="46" fill={url('mist')} opacity="0.7" />
        <path d={GROVE_NEAR} fill="#1d2d44" />
      </g>

      {/* The cliff, its lit faces, the grass on its brow, and the traveller. */}
      <g data-layer="cliff" style={depth(1)}>
        <path
          d="M110 560L150 500L190 470L214 430L240 408L270 399L310 396L350 395L392 397L424 401L452 408L468 418L476 440L490 470L500 500L516 540L522 560Z"
          fill={url('rock')}
        />
        <path
          d="M240 408L270 399L310 396L350 395L392 397L424 401L452 408L440 414L400 410L350 409L300 411L262 416Z"
          fill="#7d7472"
        />
        <path
          d="M452 408L468 418L476 440L490 470L500 500L516 540L522 560L492 560L478 520L464 472L454 442L440 414Z"
          fill="#a9846a"
        />
        <path
          d="M440 414L454 442L464 472L478 520L492 560L420 560L432 500L422 452L400 410Z"
          fill="#5f5761"
        />
        <path
          d="M262 416L300 411L350 409L400 410L422 452L380 472L322 462L280 450Z"
          fill="#4b4953"
        />
        <path d="M214 430L240 408L262 416L280 450L250 482L200 502L190 470Z" fill="#3a3a45" />
        <path d="M322 462L380 472L422 452L432 500L420 560L300 560L290 510Z" fill="#34343e" />
        <path
          d="M300 411L296 432L310 446M400 410L410 436L404 458M464 472L470 500M280 450L268 476"
          fill="none"
          stroke="#26262e"
          strokeWidth="1.4"
          strokeLinecap="round"
        />
        <path d={CLIFF_GRASS} fill="#8f8a3a" />
        <path d={CLIFF_LIGHT} fill="#cdb25e" />

        {/* The traveller, from behind: lit along the right (sun) side by a copy drawn under. */}
        <g data-part="traveller" transform="translate(336 397) scale(1.28) translate(-336 -397)">
          <g fill="#f3c98c" transform="translate(1.6 -0.6)">
            <TravellerShape />
          </g>
          <g fill="#171a24">
            <TravellerShape />
          </g>
          <path d="M323 288h26l-1 30h-24z" fill="#2a2a33" />
          <rect x="318" y="281" width="36" height="9" rx="4.5" fill="#3b3742" />
          <path d="M318 285.5h36" stroke="#c49a6a" strokeWidth="0.8" opacity="0.6" />
          <path
            d="M310 330C314 340 312 350 304 358M332 324C334 336 333 346 330 354"
            stroke="#2a2c38"
            strokeWidth="1.6"
            fill="none"
          />
        </g>
      </g>

      {/* Foreground grass and stones, nearest of all. */}
      <g data-layer="front" style={depth(1.3)}>
        <path
          d="M520 560L560 520L640 506L760 494L880 484L1060 470L1060 560Z"
          fill={url('ground')}
        />
        <path d="M700 520L728 500L770 498L790 516L760 530Z" fill="#55545d" />
        <path d="M728 500L770 498L760 510Z" fill="#9b8a77" />
        <path d={FRONT_GRASS} fill="#6b7232" />
        <path d={tufts(21, 560, 1040, (x) => 530 - (x - 560) * 0.05, 14)} fill="#b9a24e" />
        <path d="M-100 560L-100 500L60 504L210 512L230 560Z" fill={url('ground')} />
        <path d={LEFT_GRASS} fill="#5d6630" />
      </g>

      {/* Birds crossing near the sun. */}
      <g data-layer="birds" fill="#3d3a44">
        <path d="M610 92q5-5 9 0q4-5 9 0q-4-2-9 1q-5-3-9-1Z" />
        <path d="M640 108q4-4 7 0q3-4 7 0q-3-2-7 1q-4-3-7-1Z" />
        <path d="M586 116q4-4 7 0q3-4 7 0q-3-2-7 1q-4-3-7-1Z" />
        <path d="M662 84q3-3 6 0q3-3 6 0q-3-1.5-6 1q-3-2.5-6-1Z" />
        <path d="M700 60q3-3 6 0q3-3 6 0q-3-1.5-6 1q-3-2.5-6-1Z" />
      </g>
    </svg>
  );
}

/** The traveller's outline: hooded head, shoulders, a cloak blown to the left, legs, a staff. */
function TravellerShape() {
  return (
    <>
      <path d="M327.5 268.5a8.5 10 0 1 1 17 0l-0.5 9h-16z" />
      <path d="M316 280C322 276 350 276 356 281L360 300L358 330L352 352L338 356L320 354L302 360L288 356L300 336L308 300Z" />
      <path d="M321 352h10l-1 40h-12z" />
      <path d="M338 352h10l3 40h-12z" />
      <path d="M314 392h17v5h-19z" />
      <path d="M337 392h16l2 5h-18z" />
      <path d="M355 284l8 24l-2 26l-6 0l1-24l-6-18z" />
      <path d="M359.5 252l3-1l10 147l-3 0.5z" />
    </>
  );
}
```

### title-screen/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 };
}
```