# Ability select

> A pause-tablet page for picking an ability: glowing glyph tiles, a highlight that glides, and a crossfading description.

The page of a game’s pause tablet where the player picks what the shoulder button fires. A row of square tiles, each glyph in its own colour, the picked one lit blue with a cyan glow; its name above in large cyan, and below it what the button does, with the one thing to remember in red. Walk the row with the arrow keys: the highlight glides, the words crossfade, the ends nudge back. Upgrades wear a cream “+”; slots still to earn stay empty.

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

Category: Games · Menus.

## Anatomy

1. **Page tab**: The top band of the bezel: a carved sandstone rail, the page’s name in glowing cyan and the shoulder prompt to the next page.
2. **Ability name**: The picked ability in large italic cyan, with a “+” once upgraded. On a change the old name fades as the new one slides in from the side the highlight came from.
3. **Ability tile**: A square `role="radio"` button: a dark plate with a slate edge and lit corners, and the ability’s glyph in its own colour.
4. **Upgrade plus**: A cream “+” in the glyph’s lower right corner, with a moat cut around it so it reads on any colour; one that lands later pops in.
5. **Highlight**: One blue tile with a cyan edge and glow, under the row; it glides to the picked tile on `translate` with a small overshoot, and the tile’s own plate fades.
6. **Empty slot**: A faint square for an ability still to earn; the arrows skip it and it cannot be clicked.
7. **Heading**: What the button does, in italic cyan on one line, over a divider with a capped end at each side.
8. **Red mark**: The one thing to remember in the description, a `mark` drawn in red; the whole copy crossfades when the pick changes.

## When to use

- A page of a pause menu where the player picks which ability one button fires, from a short row they walk with left and right.
- Tools the player recognises by colour and shape first, so each one is a glyph on a square tile, with its name only for the picked one.
- A pick that needs a line of explanation: what the button does, with the one thing to remember picked out in red.
- A set that grows over the game: empty slots promise what is still to earn, and a cream “+” marks the upgraded ones.
- A choice made with a controller or arrow keys: one highlight that glides, Enter or A to confirm, the ends nudging back.

## When not to use

- Picking an ability in the middle of play. Use a quick selector or a radial wheel that opens over the game while a button is held.
- A loadout of many items to compare by stats or counts. Use an item grid with a detail panel.
- A toolbar or segmented control in an app. Use a toggle group with labels; glyphs alone ask the reader to learn them.
- Showing which abilities are ready or cooling down while playing. Use HUD ability icons with a cooldown sweep.
- Moving between the pages of a pause menu. Use menu tabs; the tab in the top band here only names the page.

## Installation

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

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

export function Tablet() {
  return (
    <AbilitySelect
      abilities={['round-bomb', 'square-bomb', 'magnet', 'lock', 'camera']}
      slots={8}
      defaultSelected="magnet"
      upgraded={['round-bomb', 'lock']}
      onConfirm={(ability) => console.log('equipped', ability)}
      autoplay={false}
    />
  );
}
```

## States

- **Default**: The defaults with `autoplay={false}`: all seven abilities, the last one picked, three upgraded, one empty slot.
- **First tile**: `defaultSelected="round-bomb"`: the highlight at the start of the row, on an upgraded ability with its own lines.
- **Not upgraded**: `defaultSelected="magnet"`: no “+” on the name, the base heading and lines.
- **Upgraded**: `defaultSelected="lock"`: the name reads Time Lock + and the lines are the upgrade’s.
- **No upgrades**: `upgraded={[]}`: no tile wears a “+”.
- **Early in the game**: Three abilities in eight slots: five empty squares wait for what is still to earn.
- **Full row**: `slots={7}` with all seven abilities: no empty slot.
- **Own words**: `texts` replaces an ability’s name, heading and lines; a piece in `{ mark }` is drawn in red.
- **Compact**: Under 700 px the tablet switches to a 520-unit layout as tall as its content; words never go under 11 px.

## How it works

- The tablet is drawn whole: a dark bezel whose top and bottom bands carry a carved sandstone rail, four stepped corner keys with lit cores, and a navy screen lit blue along its top and bottom edges, over a faint dot grid. Everything is SVG and CSS, sized in a unit that is 1 px on a 960 × 540 tablet.
- Each ability is a flat SVG glyph in its own colour, its details cut out with a mask. An upgrade adds a cream “+” with a moat cut around it, so it reads on any colour.
- The highlight is one element under the tiles. It glides to the picked tile by a transition on translate (its index times the tile pitch) with a little overshoot and a settle, while the tile’s own dark plate fades so the glyph seems to sit in the light.
- The name above and the description below crossfade: the old copy stays a moment, stacked in the same grid cell, fading out as the new one fades in; the name also slides in from the side the highlight came from.
- Left and right walk the row and skip the empty slots. At either end the highlight nudges and springs back (or goes round, with wrap). Enter, Space or a click on the picked tile confirms it with a press pulse. A “+” that lands later pops in, and its tile flashes. Nudges, pulses and pops are Web Animations, skipped under reduced motion, where the copy also swaps without fading.
- While on screen and untouched, the page plays a short session: walking the row, an upgrade landing on the ice block, a quick scrub back, a confirm, the end of the row. Any input of the reader’s ends it.
- Like a game’s menu, the tablet brings its own dark surface, so it reads the same on a light or a dark stage; only its halo and shadow on the page follow the colour scheme. Under 700 px it switches to a compact layout that grows to its content, and words never go under 11 px.

## API reference

- **abilities** (`AbilityId[]`, default `all seven`): The abilities owned, in the row's order: 'round-bomb', 'square-bomb', 'magnet', 'lock', 'ice', 'camera', 'bike'.
- **slots** (`number`, default `8`): How many tiles the row has; those past the abilities are empty slots.
- **defaultSelected** (`AbilityId`, default `the last`): The ability picked at first.
- **selected** (`AbilityId`): The picked ability, controlled; pair it with onSelectedChange. Turns the demo off.
- **onSelectedChange** (`(ability: AbilityId) => void`): Called when another ability is picked.
- **upgraded** (`AbilityId[]`, default `['round-bomb', 'square-bomb', 'lock']`): The abilities that wear the “+” of an upgrade, and show their upgraded text.
- **onConfirm** (`(ability: AbilityId) => void`): Called when the picked ability is confirmed: Enter, Space, or a click on it.
- **texts** (`Partial<Record<AbilityId, Partial<AbilityText>>>`): Your own name, heading and lines per ability. A line is a list of pieces; a piece written { mark: "…" } is picked out in red. `upgraded` holds the text shown once upgraded.
- **wrap** (`boolean`, default `false`): Whether walking past either end goes round to the other; otherwise the highlight nudges back.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, the page plays a short session. Any input hands it to the reader.
- **colorScheme** (`'light' | 'dark' | 'system'`): The stage's scheme, for the tablet's halo and shadow; without it, the page's color-scheme.
- **className** (`string`): A class for the root.

## Accessibility

- The row is a radiogroup of real buttons with role radio: one tab stop, arrow keys, Home and End move the pick, and focus follows it. Empty slots are hidden from assistive technology and skipped.
- Each ability is named, with “upgraded” when it wears a “+”; the picked one is described by its heading and lines, so moving to it reads what it does.
- Confirming and upgrades are announced in a polite live region.
- The bezel, the highlight, the flourishes and the glyphs are decorative and hidden; the red words are a mark element, and colour is never the only cue.
- Focus shows a visible ring outside the tile. Under reduced motion the glide, the pops and the demo are off, and the copy swaps without fading.

## Source

### ability-select/ability-select.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 { textOf, plain, type AbilityText, type Piece } from './abilities';
import { Corner, Rail } from './bezel';
import { ABILITY_IDS, AbilityGlyph, type AbilityId } from './glyphs';
import { useAutoplay } from './use-autoplay';
import styles from './ability-select.module.css';

export { AbilityGlyph, ABILITY_IDS, type AbilityId } from './glyphs';
export { TEXTS, type AbilityText, type Piece } from './abilities';

/*
 * Ability select: the page of a game's pause tablet where the player picks the ability the
 * shoulder button fires. A row of square tiles, one glyph each in its own colour, the picked one
 * lit blue with a cyan glow; its name in large cyan above the row, and below it what the button
 * does, with the one thing to remember picked out in red. Upgraded abilities wear a cream "+";
 * slots still to be earned stay empty.
 *
 * - The tablet is drawn whole: a dark bezel with carved sandstone corners and rails (bezel.tsx), a
 *   navy screen lit cyan from its edges. Like a game's menu it brings its own dark surface, so it
 *   reads the same on a light or a dark page; only its shadow on the page follows the scheme.
 * - The highlight is one element under the tiles that glides to the picked one: a transition on
 *   `translate`, the index times the tile pitch, with a little overshoot and a settle. The tile's
 *   own dark plate fades as the highlight arrives, so the glyph seems to sit in the light.
 * - The name and the description crossfade: the old copy stays a moment, stacked in the same
 *   grid cell, fading out as the new one fades in (the name also slides the way the highlight went).
 * - Left and right (arrow keys, or a click) walk the row; empty slots are skipped. At either end
 *   the highlight nudges and springs back, unless `wrap` is on. Enter, Space or a click on the
 *   picked tile confirms it: a press pulse, and onConfirm.
 * - A "+" that lands after the first paint pops in, with a flash on its tile.
 * - Moves are CSS transitions; nudges, pulses and pops are Web Animations. Under reduced motion
 *   the glide and the pops are skipped and the copy swaps without fading.
 * - While on screen and untouched, the page plays a short session: walking the row, an upgrade
 *   landing, a confirm, the end of the row. Any input of the reader's ends it.
 */

type Step = [number, { move?: -1 | 1; upgrade?: AbilityId; confirm?: boolean; reset?: boolean }];
const SCRIPT: Step[] = [
  [1500, { move: -1 }],
  [1500, { move: -1 }],
  [1200, { upgrade: 'ice' }],
  [1900, { move: -1 }],
  [1500, { move: -1 }],
  [1400, { move: -1 }],
  [1400, { move: -1 }],
  [1500, { move: -1 }],
  [1000, { move: 1 }],
  [280, { move: 1 }],
  [280, { move: 1 }],
  [280, { move: 1 }],
  [280, { move: 1 }],
  [280, { move: 1 }],
  [1500, { confirm: true }],
  [1300, { move: 1 }],
  [2400, { reset: true }],
];

const DEFAULT_UPGRADED: AbilityId[] = ['round-bomb', 'square-bomb', 'lock'];

type Shown = { id: AbilityId; up: boolean };

function Words({ text }: { text: Piece[] }) {
  return text.map((piece, index) =>
    typeof piece === 'string' ? (
      piece
    ) : (
      <mark key={index} className={styles.mark} data-part="mark">
        {piece.mark}
      </mark>
    ),
  );
}

export function AbilitySelect({
  abilities = ABILITY_IDS,
  slots = 8,
  selected: selectedProp,
  defaultSelected,
  onSelectedChange,
  upgraded = DEFAULT_UPGRADED,
  onConfirm,
  texts,
  wrap = false,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The abilities owned, in the row's order. */
  abilities?: AbilityId[];
  /** How many tiles the row has; those past the abilities are empty slots. */
  slots?: number;
  /** The picked ability, controlled; pair it with onSelectedChange. */
  selected?: AbilityId;
  /** The ability picked at first, uncontrolled (the last one by default). */
  defaultSelected?: AbilityId;
  /** Called when the reader (or the demo) picks another ability. */
  onSelectedChange?: (ability: AbilityId) => void;
  /** The abilities that wear the "+" of an upgrade. */
  upgraded?: AbilityId[];
  /** Called when the picked ability is confirmed (Enter, Space, or a click on it). */
  onConfirm?: (ability: AbilityId) => void;
  /** Your own names, headings and lines, per ability, over the built-in ones. */
  texts?: Partial<Record<AbilityId, Partial<AbilityText>>>;
  /** Whether walking past either end goes round to the other; otherwise the highlight nudges. */
  wrap?: boolean;
  /** While on screen and untouched, the page plays a short session. */
  autoplay?: boolean;
  /** A light or dark stage, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take, reduced } = useAutoplay<HTMLDivElement>(6000);
  const list = abilities.length ? abilities : ABILITY_IDS;
  const total = Math.max(slots, list.length);
  const [ownSelected, setOwnSelected] = useState<AbilityId>(
    defaultSelected && list.includes(defaultSelected) ? defaultSelected : list[list.length - 1],
  );
  const selected = selectedProp && list.includes(selectedProp) ? selectedProp : ownSelected;
  const index = list.indexOf(selected);
  /** Upgrades the demo hands out, on top of the prop's, until it starts over. */
  const [demoUpgraded, setDemoUpgraded] = useState<AbilityId[]>([]);
  const isUp = (id: AbilityId) => upgraded.includes(id) || demoUpgraded.includes(id);
  const [message, setMessage] = useState('');
  const descId = useId();

  const highlight = useRef<HTMLSpanElement>(null);
  const tiles = useRef(new Map<AbilityId, HTMLButtonElement>());
  const plusSeen = useRef<Set<AbilityId> | null>(null);

  // What the name and the description show, and what they showed before, kept while it fades out.
  const up = isUp(selected);
  const [view, setView] = useState<{ cur: Shown; prev: Shown | null; dir: number; n: number }>({
    cur: { id: selected, up },
    prev: null,
    dir: 0,
    n: 0,
  });
  if (view.cur.id !== selected || view.cur.up !== up) {
    const from = list.indexOf(view.cur.id);
    setView({
      cur: { id: selected, up },
      prev: reduced ? null : view.cur,
      dir: from < 0 || from === index ? 0 : Math.sign(index - from),
      n: view.n + 1,
    });
  }
  const clearPrev = () => setView((current) => ({ ...current, prev: null }));

  const animate = (
    element: Element | null | undefined,
    frames: Keyframe[],
    options: KeyframeAnimationOptions,
  ) => {
    if (!element || reduced) return;
    element.animate(frames, options);
  };

  const select = (id: AbilityId) => {
    if (id === selected) return;
    setOwnSelected(id);
    onSelectedChange?.(id);
  };

  const nudge = (dir: number) =>
    animate(
      highlight.current?.firstElementChild,
      [
        { transform: 'translateX(0)' },
        { transform: `translateX(${dir * 9}%)`, offset: 0.3 },
        { transform: `translateX(${dir * -4}%)`, offset: 0.65 },
        { transform: 'translateX(0)' },
      ],
      { duration: 340, easing: 'ease-out' },
    );

  /** One step left or right, as a d-pad would; returns the ability it lands on, if any. */
  const move = (dir: -1 | 1) => {
    let next = index + dir;
    if (next < 0 || next >= list.length) {
      if (!wrap || list.length < 2) {
        nudge(dir);
        return null;
      }
      next = (next + list.length) % list.length;
    }
    select(list[next]);
    return list[next];
  };

  const confirm = () => {
    animate(
      highlight.current?.firstElementChild,
      [
        { transform: 'scale(1)', filter: 'brightness(1)' },
        { transform: 'scale(0.92)', filter: 'brightness(1.1)', offset: 0.25 },
        { transform: 'scale(1.06)', filter: 'brightness(1.5)', offset: 0.55 },
        { transform: 'scale(1)', filter: 'brightness(1)' },
      ],
      { duration: 420, easing: 'cubic-bezier(0.3, 0.7, 0.4, 1)' },
    );
    animate(
      tiles.current.get(selected)?.querySelector('svg'),
      [
        { transform: 'scale(1)' },
        { transform: 'scale(0.86)', offset: 0.25 },
        { transform: 'scale(1.12)', offset: 0.55 },
        { transform: 'scale(1)' },
      ],
      { duration: 420, easing: 'cubic-bezier(0.3, 0.7, 0.4, 1)' },
    );
    setMessage(`${textOf(selected, up, texts?.[selected]).name} equipped.`);
    onConfirm?.(selected);
  };

  // A "+" that appears after the first paint pops in, and its tile flashes.
  useEffect(() => {
    const seen = plusSeen.current;
    const now = new Set(list.filter(isUp));
    plusSeen.current = now;
    if (!seen) return;
    for (const id of now) {
      if (seen.has(id)) continue;
      const tile = tiles.current.get(id);
      animate(
        tile?.querySelector('[data-part="plus"]'),
        [
          { transform: 'scale(0) rotate(-90deg)', opacity: 0 },
          { transform: 'scale(1.35) rotate(0deg)', opacity: 1, offset: 0.6 },
          { transform: 'scale(1) rotate(0deg)', opacity: 1 },
        ],
        { duration: 480, easing: 'cubic-bezier(0.3, 0.7, 0.4, 1)' },
      );
      animate(
        tile?.querySelector('[data-part="flash"]'),
        [{ opacity: 0 }, { opacity: 1, offset: 0.25 }, { opacity: 0 }],
        { duration: 620, easing: 'ease-out' },
      );
      setMessage(`${textOf(id, false, texts?.[id]).name} upgraded.`);
    }
  });

  // The demo: a short session, one step per beat, from the top each time it starts.
  const step = useEffectEvent((action: Step[1]) => {
    if (action.move) move(action.move);
    if (action.upgrade && list.includes(action.upgrade))
      setDemoUpgraded((current) => [...current, action.upgrade!]);
    if (action.confirm) confirm();
    if (action.reset) {
      setDemoUpgraded([]);
      setMessage('');
    }
  });
  const demo = autoplay && playing && selectedProp === undefined;
  useEffect(() => {
    if (!demo) return;
    let at = 0;
    let timer = 0;
    const next = () => {
      const [beat, action] = SCRIPT[at];
      timer = window.setTimeout(() => {
        step(action);
        at = (at + 1) % SCRIPT.length;
        next();
      }, beat);
    };
    next();
    return () => window.clearTimeout(timer);
  }, [demo]);

  const onRowKey = (event: ReactKeyboardEvent) => {
    let target: AbilityId | null = null;
    switch (event.key) {
      case 'ArrowRight':
      case 'ArrowDown':
        target = move(1);
        break;
      case 'ArrowLeft':
      case 'ArrowUp':
        target = move(-1);
        break;
      case 'Home':
        target = list[0];
        select(target);
        break;
      case 'End':
        target = list[list.length - 1];
        select(target);
        break;
      case 'Enter':
      case ' ':
        event.preventDefault();
        confirm();
        return;
      default:
        return;
    }
    event.preventDefault();
    if (target) tiles.current.get(target)?.focus();
  };

  const shown = (entry: Shown, leaving: boolean) => {
    const copy = textOf(entry.id, entry.up, texts?.[entry.id]);
    return { copy, key: `${entry.id}${entry.up ? '+' : ''}-${leaving ? view.n - 1 : view.n}` };
  };
  const cur = shown(view.cur, false);
  const prev = view.prev ? shown(view.prev, true) : null;
  const changed = view.n > 0 ? '' : undefined;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="root"
      data-color-scheme={colorScheme}
      onPointerDown={take}
      onKeyDown={take}
      onWheel={take}
    >
      <div className={styles.tablet} data-part="tablet">
        <div className={styles.bar} data-part="bar" aria-hidden="true">
          <Rail className={styles.rail} />
          <span className={styles.tab} data-part="tab">
            Abilities
          </span>
          <span className={styles.next}>
            <span className={styles.key}>R</span>
            Map
          </span>
        </div>

        <div className={styles.screen} data-part="screen">
          <div className={styles.head}>
            <p className={styles.name} data-part="name" aria-hidden="true">
              {prev && (
                <span
                  key={prev.key}
                  className={styles.nameText}
                  data-leaving=""
                  onAnimationEnd={clearPrev}
                >
                  {prev.copy.name}
                </span>
              )}
              <span
                key={cur.key}
                className={styles.nameText}
                data-entering={changed}
                style={{ '--dir': view.dir } as CSSProperties}
              >
                {cur.copy.name}
              </span>
            </p>
            <svg
              className={styles.flourish}
              viewBox="0 0 92 8"
              aria-hidden="true"
              focusable="false"
            >
              <path d="M0 4h30M62 4h30" />
              <path d="M34 4l4-3 4 3-4 3zM50 4l4-3 4 3-4 3z" />
              <circle cx="46" cy="4" r="2.2" />
            </svg>
          </div>

          <div
            className={styles.row}
            role="radiogroup"
            aria-label="Abilities"
            data-part="row"
            style={{ '--sel': index, '--slots': total } as CSSProperties}
            onKeyDown={onRowKey}
          >
            <span
              ref={highlight}
              className={styles.highlight}
              data-part="highlight"
              aria-hidden="true"
            >
              <span className={styles.highlightInner} />
            </span>
            {Array.from({ length: total }, (_, slot) => {
              const id = list[slot];
              if (!id)
                return (
                  <span
                    key={`empty-${slot}`}
                    className={styles.empty}
                    data-part="empty"
                    aria-hidden="true"
                    style={{ '--i': slot } as CSSProperties}
                  />
                );
              const name = textOf(id, isUp(id), texts?.[id]).name;
              return (
                <button
                  key={id}
                  ref={(element) => {
                    if (element) tiles.current.set(id, element);
                    else tiles.current.delete(id);
                  }}
                  type="button"
                  role="radio"
                  aria-checked={id === selected}
                  aria-label={isUp(id) ? name.replace(/ \+$/, ', upgraded') : name}
                  aria-describedby={id === selected ? descId : undefined}
                  tabIndex={id === selected ? 0 : -1}
                  className={styles.tile}
                  data-part="tile"
                  data-selected={id === selected ? '' : undefined}
                  style={{ '--i': slot } as CSSProperties}
                  onClick={() => (id === selected ? confirm() : select(id))}
                >
                  <span className={styles.flash} data-part="flash" aria-hidden="true" />
                  <AbilityGlyph ability={id} upgraded={isUp(id)} className={styles.glyph} />
                </button>
              );
            })}
          </div>

          <div className={styles.desc} data-part="description">
            {prev && (
              <div key={prev.key} className={styles.copy} data-leaving="" aria-hidden="true">
                <p className={styles.heading}>{prev.copy.heading}</p>
                <Divider />
                <p className={styles.text}>
                  <Words text={prev.copy.text} />
                </p>
              </div>
            )}
            <div key={cur.key} className={styles.copy} data-entering={changed}>
              <p className={styles.heading} data-part="heading">
                {cur.copy.heading}
              </p>
              <Divider />
              <p className={styles.text} data-part="text">
                <Words text={cur.copy.text} />
              </p>
              <span id={descId} hidden>
                {`${cur.copy.heading}. ${plain(cur.copy.text)}`}
              </span>
            </div>
          </div>
        </div>

        <div className={styles.foot} aria-hidden="true">
          <Rail className={styles.rail} />
        </div>
        {['tl', 'tr', 'bl', 'br'].map((at) => (
          <Corner key={at} className={styles.corner} at={at} />
        ))}
      </div>
      <p className={styles.visuallyHidden} aria-live="polite" role="status">
        {message}
      </p>
    </div>
  );
}

/** The line under the heading, with a capped end at each side. */
function Divider() {
  const cap = (
    <svg className={styles.cap} viewBox="0 0 24 10" aria-hidden="true" focusable="false">
      <circle cx="5" cy="5" r="3.4" />
      <path d="M8.4 5h5" />
      <path d="M14 5l3.4-3 3.4 3-3.4 3z" />
    </svg>
  );
  return (
    <span className={styles.divider} data-part="divider" aria-hidden="true">
      {cap}
      <span className={styles.line} />
      {cap}
    </span>
  );
}
```

### ability-select/abilities.ts

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

import type { AbilityId } from './glyphs';

/*
 * What the page says about each ability: its name, a heading that says what the button does, and
 * two or three short lines. A line is a list of pieces; a piece in `{ mark }` is the one thing to
 * remember, picked out in red. An upgraded ability has its own heading and lines.
 */

export type Piece = string | { mark: string };

export type AbilityText = {
  /** The name above the row, without the "+" of an upgrade (added on its own). */
  name: string;
  heading: string;
  text: Piece[];
  /** What the page says once the ability is upgraded; without it, the same as above. */
  upgraded?: { heading?: string; text: Piece[] };
};

export const TEXTS: Record<AbilityId, AbilityText> = {
  'round-bomb': {
    name: 'Round Bomb',
    heading: 'Throw Round Bomb',
    text: [
      { mark: 'Press L again to set it off' },
      ' from a safe distance. It rolls down slopes, so watch where it comes to rest.',
    ],
    upgraded: {
      text: [
        { mark: 'The blast reaches farther' },
        ' and the next bomb is ready in half the time. Press L again to set it off.',
      ],
    },
  },
  'square-bomb': {
    name: 'Square Bomb',
    heading: 'Throw Square Bomb',
    text: [
      'It stays where it lands, even on a slope. ',
      { mark: 'Press L again to set it off' },
      ' once you are clear.',
    ],
    upgraded: {
      text: [
        { mark: 'A wider blast' },
        ', and it is ready again sooner. It stays where it lands, even on a slope.',
      ],
    },
  },
  magnet: {
    name: 'Magnet',
    heading: 'Use Magnet',
    text: [
      { mark: 'Lifts and moves metal objects' },
      ' from a distance. Turn to swing them; tilt the stick to bring them closer.',
    ],
    upgraded: {
      text: [
        { mark: 'Lifts heavier metal' },
        ' and holds it longer. Turn to swing it; tilt the stick to bring it closer.',
      ],
    },
  },
  lock: {
    name: 'Time Lock',
    heading: 'Use Time Lock',
    text: [
      { mark: 'Freezes one object in time' },
      ' for a few seconds. Strike it while it is held and it flies off with the force it gathered.',
    ],
    upgraded: {
      text: [
        { mark: 'Can also hold a creature' },
        ' for a moment. A struck object flies off with all the force it gathered.',
      ],
    },
  },
  ice: {
    name: 'Ice Block',
    heading: 'Use Ice Block',
    text: [
      { mark: 'Raises a pillar of ice from water' },
      '. Climb it, block a stream or lift what floats. Up to three can stand at once.',
    ],
    upgraded: {
      text: [
        { mark: 'Pillars rise faster' },
        ' and up to four can stand at once. Climb them, block a stream or lift what floats.',
      ],
    },
  },
  camera: {
    name: 'Camera',
    heading: 'Use Camera',
    text: [
      'Takes a picture of whatever is in front of you. ',
      { mark: 'New creatures you capture' },
      ' are added to your field guide.',
    ],
    upgraded: {
      text: [
        'Zooms twice as far. ',
        { mark: 'New creatures you capture' },
        ' are added to your field guide.',
      ],
    },
  },
  bike: {
    name: 'Motorbike',
    heading: 'Call Motorbike',
    text: [
      { mark: 'Load materials into its tank' },
      ' to make fuel. It comes whenever you call, except in a few places where it cannot ride.',
    ],
    upgraded: {
      text: [
        { mark: 'Fuel lasts twice as long' },
        '. It comes whenever you call, except in a few places where it cannot ride.',
      ],
    },
  },
};

/** The page's text for an ability, upgraded or not, with an optional override per ability. */
export function textOf(id: AbilityId, upgraded: boolean, override?: Partial<AbilityText>) {
  const base = { ...TEXTS[id], ...override };
  const up = upgraded ? (override?.upgraded ?? TEXTS[id].upgraded) : undefined;
  return {
    name: base.name + (upgraded ? ' +' : ''),
    heading: up?.heading ?? base.heading,
    text: up?.text ?? base.text,
  };
}

/** A line's words as plain text, for screen readers. */
export const plain = (text: Piece[]) =>
  text.map((piece) => (typeof piece === 'string' ? piece : piece.mark)).join('');
```

### ability-select/glyphs.tsx

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

'use client';

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

/*
 * The ability glyphs: flat, bold silhouettes, one colour each, drawn on a 54-unit square (the
 * motorbike is wider and spills 5 units out each side, so every glyph sits in the same 64 × 60
 * box). Details are cut out of the shape, never painted over it, so whatever is behind shows
 * through them: each glyph is a filled group under a mask whose black shapes are the cuts.
 *
 * An upgraded glyph gets a cream "+" in its lower right corner, with a 2.5-unit moat cut around it
 * so the badge reads on top of any colour.
 */

export type AbilityId =
  'round-bomb' | 'square-bomb' | 'magnet' | 'lock' | 'ice' | 'camera' | 'bike';

export const ABILITY_IDS: AbilityId[] = [
  'round-bomb',
  'square-bomb',
  'magnet',
  'lock',
  'ice',
  'camera',
  'bike',
];

/** Each ability's colour, which the player learns before its shape. */
export const COLORS: Record<AbilityId, string> = {
  'round-bomb': '#00c2ff',
  'square-bomb': '#00c2ff',
  magnet: '#fe4a67',
  lock: '#ffe23f',
  ice: '#b2e7fa',
  camera: '#a4be00',
  bike: '#ffa800',
};

/** The "+" badge, 22 units square, its middle at (44, 46). */
const PLUS = 'M40 35h8v7h7v8h-7v7h-8v-7h-7v-8h7z';

type Art = { shape: ReactNode; cuts?: ReactNode };

const ART: Record<AbilityId, Art> = {
  'round-bomb': {
    shape: (
      <>
        <circle cx="23" cy="31.5" r="21.5" />
        <rect
          x="-6.5"
          y="-3.5"
          width="13"
          height="7"
          rx="1.5"
          transform="translate(37.6 16.9) rotate(45)"
        />
        <path
          d="M40.5 14 44 10.5"
          fill="none"
          stroke="currentColor"
          strokeWidth="3.5"
          strokeLinecap="round"
        />
        <circle cx="46" cy="8.5" r="6.5" />
      </>
    ),
    cuts: (
      <>
        <path d="M29.3 12.4 42.1 25.2" strokeWidth="2.2" />
        <circle cx="46" cy="8.5" r="2.7" />
      </>
    ),
  },
  'square-bomb': {
    shape: (
      <>
        <rect x="5.5" y="17" width="43" height="37" rx="1.5" />
        <rect x="19" y="10" width="16" height="5.5" rx="1.2" />
        <circle cx="27" cy="6.5" r="6" />
      </>
    ),
    cuts: <circle cx="27" cy="6.5" r="2.5" />,
  },
  magnet: {
    shape: (
      <g transform="rotate(45 27 27)">
        <path
          d="M12.5 1V27a14.5 14.5 0 0 0 29 0V1"
          fill="none"
          stroke="currentColor"
          strokeWidth="10.5"
        />
      </g>
    ),
    cuts: (
      <g transform="rotate(45 27 27)">
        <path d="M5 9h15M34 9h15" strokeWidth="2.4" />
      </g>
    ),
  },
  lock: {
    shape: (
      <>
        <rect x="5.5" y="24" width="43" height="29.5" rx="3" />
        <path
          d="M14.5 26v-9a12.5 12.5 0 0 1 25 0v9"
          fill="none"
          stroke="currentColor"
          strokeWidth="7.5"
        />
      </>
    ),
    cuts: (
      <>
        <circle cx="27" cy="36.5" r="4.4" stroke="none" />
        <path d="M25 38.5 23.6 46.5h6.8L29 38.5z" stroke="none" />
      </>
    ),
  },
  ice: {
    shape: (
      <g fill="none" stroke="currentColor" strokeLinecap="round">
        {[0, 60, 120, 180, 240, 300].map((angle) => (
          <g key={angle} transform={`rotate(${angle} 27 27)`}>
            <path d="M27 27V3.5" strokeWidth="5.5" />
            <path d="M27 15.5l-6 -6M27 15.5l6 -6" strokeWidth="4.5" />
          </g>
        ))}
        <circle cx="27" cy="27" r="6.5" fill="currentColor" stroke="none" />
      </g>
    ),
    cuts: (
      <>
        <circle cx="27" cy="27" r="2.6" stroke="none" />
      </>
    ),
  },
  camera: {
    shape: (
      <>
        <rect x="3" y="14" width="48" height="34" rx="2.5" />
        <path d="M18 15 22 7.5Q23 6 24.8 6h10.4q1.8 0 2.8 1.5L42 15z" />
        <rect x="6" y="9" width="9" height="3.5" rx="1.5" />
      </>
    ),
    cuts: <circle cx="30" cy="31" r="10.3" fill="none" strokeWidth="3.2" />,
  },
  bike: {
    shape: (
      <>
        <circle cx="5.5" cy="39.5" r="9" />
        <circle cx="47.5" cy="39.5" r="9" />
        <path d="M-2 21h12q4 0 6-3l3-5q1.5-2 4-1.5l6 1.5q3 1 4 4l2 4h15l5-4.5 2 1.5-3 8q-2 3-6 3.5l-8 1-6 6.5q-1 1.5-3 1.5h-9q-2.5 0-4-2l-4-6.5H6q-3 0-4-2.5z" />
        <path
          d="M36 13 42 10M44 22l3.5 17.5"
          fill="none"
          stroke="currentColor"
          strokeWidth="4"
          strokeLinecap="round"
        />
      </>
    ),
    cuts: (
      <>
        <circle cx="5.5" cy="39.5" r="3.2" stroke="none" />
        <circle cx="47.5" cy="39.5" r="3.2" stroke="none" />
        <circle cx="5.5" cy="39.5" r="10.4" fill="none" strokeWidth="2" />
        <circle cx="47.5" cy="39.5" r="10.4" fill="none" strokeWidth="2" />
      </>
    ),
  },
};

/** One ability's glyph: an inline SVG, sized by its className (64 × 60 units). */
export function AbilityGlyph({
  ability,
  upgraded = false,
  className,
  title,
}: {
  ability: AbilityId;
  /** Shows the cream "+" badge of an upgraded ability. */
  upgraded?: boolean;
  className?: string;
  /** A name for screen readers; without it the glyph is decorative. */
  title?: string;
}) {
  const id = useId();
  const art = ART[ability];
  return (
    <svg
      className={className}
      viewBox="-5 -3 64 60"
      focusable="false"
      role={title ? 'img' : undefined}
      aria-hidden={title ? undefined : true}
      aria-label={title}
      style={{ color: COLORS[ability] }}
      data-ability={ability}
    >
      <mask id={id} maskUnits="userSpaceOnUse" x="-5" y="-3" width="64" height="60">
        <rect x="-5" y="-3" width="64" height="60" fill="#fff" />
        <g fill="#000" stroke="#000">
          {art.cuts}
        </g>
        {upgraded && (
          <path d={PLUS} fill="#000" stroke="#000" strokeWidth="5" strokeLinejoin="round" />
        )}
      </mask>
      <g mask={`url(#${id})`} fill="currentColor">
        {art.shape}
      </g>
      {upgraded && (
        <path
          data-part="plus"
          d={PLUS}
          fill="#fefbe3"
          stroke="#fefbe3"
          strokeWidth="1"
          strokeLinejoin="round"
        />
      )}
    </svg>
  );
}
```

### ability-select/bezel.tsx

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

'use client';

import { useId } from 'react';

/*
 * The tablet's bezel ornaments: a carved sandstone inlay, drawn as SVG strokes so it stays sharp
 * at any size. Each stroke is laid three times: a dark bed a little wider than the stone, the
 * stone, then a thin darker groove down its middle, which reads as a channel cut into it. A
 * blurred cyan copy sits behind the corners, the light leaking from the screen under the stone.
 *
 * A corner is a stepped key: an outer bracket that follows the tablet's edge and an inner line
 * that steps in towards the screen, ending in two rings with a lit core, plus a diamond stud.
 * The rails along the top and bottom bands repeat a long rounded bar and a small stud.
 */

const STONE = '#b39a6a';
const BED = '#1c160d';
const GROOVE = '#7a6544';
const CORE = '#62ddff';

const KEY = 'M7 132V34Q7 7 34 7H124M26 112V64L44 46H64V26H104';

/** One corner, drawn top-left; the stylesheet mirrors it into the other three. */
export function Corner({ className, at }: { className?: string; at: string }) {
  const id = useId();
  return (
    <svg
      className={className}
      data-at={at}
      viewBox="0 0 124 132"
      aria-hidden="true"
      focusable="false"
    >
      <filter id={id} filterUnits="userSpaceOnUse" x="-30" y="-30" width="190" height="200">
        <feGaussianBlur stdDeviation="4" />
      </filter>
      <g filter={`url(#${id})`} opacity="0.75" fill="none" stroke="#2aa6ff">
        <path d={KEY} strokeWidth="14" />
        <circle cx="26" cy="118" r="8" fill="#2aa6ff" />
        <circle cx="110" cy="26" r="8" fill="#2aa6ff" />
      </g>
      <g fill="none" strokeLinejoin="round">
        <path d={KEY} stroke={BED} strokeWidth="12.5" />
        <circle cx="26" cy="118" r="7.5" fill={BED} />
        <circle cx="110" cy="26" r="7.5" fill={BED} />
        <path d="M58 49 67 58 58 67 49 58Z" fill={BED} stroke={BED} strokeWidth="3" />
        <path d={KEY} stroke={STONE} strokeWidth="9.5" />
        <path d={KEY} stroke={GROOVE} strokeWidth="1.6" />
        <circle cx="26" cy="118" r="5.6" stroke={STONE} strokeWidth="3.2" />
        <circle cx="110" cy="26" r="5.6" stroke={STONE} strokeWidth="3.2" />
        <circle cx="26" cy="118" r="2.6" fill={CORE} />
        <circle cx="110" cy="26" r="2.6" fill={CORE} />
        <path d="M58 50.5 65.5 58 58 65.5 50.5 58Z" fill={STONE} />
        <circle cx="58" cy="58" r="2.2" fill={CORE} />
      </g>
    </svg>
  );
}

/** A rail of the band: bars and studs repeated across its width. Its 18-unit height is the
 * rail's CSS height, so the pattern scales with the tablet (the slice keeps it from stretching). */
export function Rail({ className }: { className?: string }) {
  const id = useId();
  return (
    <svg
      className={className}
      viewBox="0 0 1000 18"
      preserveAspectRatio="xMidYMid slice"
      aria-hidden="true"
      focusable="false"
    >
      <pattern id={id} width="58" height="18" patternUnits="userSpaceOnUse" x="0" y="0">
        <rect
          x="2"
          y="3.5"
          width="45"
          height="11"
          rx="5.5"
          fill={STONE}
          stroke={BED}
          strokeWidth="1.6"
        />
        <path d="M8 9h33" stroke={GROOVE} strokeWidth="1.4" strokeLinecap="round" />
        <circle cx="52.5" cy="9" r="3.2" fill={STONE} stroke={BED} strokeWidth="1.4" />
      </pattern>
      <rect width="1000" height="18" fill={`url(#${id})`} />
    </svg>
  );
}
```

### ability-select/use-autoplay.ts

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

'use client';

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

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

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

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

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

### ability-select/ability-select.module.css

```css
/* Ability select (ability-select.tsx): a page of a game's pause tablet, centred on a plain stage.
   Every size is a multiple of --u, 1 px at full size (a 960 × 540 tablet), less on a narrower
   stage. Under 700 px the tablet switches to a compact layout 520 units wide, with its own
   sizes, and grows as tall as its content instead of keeping 16:9; words never go under 11 px.

   The tablet's bezel and screen are dark on any page, as a game draws them; only its edge and its
   shadow on the page follow the colour scheme, through light-dark() tokens.
   No filter touches a glyph or a word: glows are box- and text-shadows behind the sharp shapes.

   Motion: the highlight glides by a transition on translate; the name and the description
   crossfade by keyframes; nudges, pulses and pops are Web Animations (in the component). */

.root {
  /* Words on the screen. */
  --cyan: #3cd3fc;
  --cyan-glow: rgb(24 160 255 / 0.85);
  --mark: #f0484a;
  /* The screen: a navy field, lit blue along its top and bottom edges. */
  --navy: #191b31;
  --navy-dot: rgb(140 156 236 / 0.075);
  --edge-light: #05428c;
  /* The bezel. */
  --bezel: #1a1a1b;
  --bezel-deep: #101011;
  /* Tiles. */
  --tile: #050c15;
  --tile-mid: #0d1724;
  --tile-line: #4b6477;
  --tile-inner: #1e3142;
  --tile-corner: #8fb2bd;
  --pick: #1474ad;
  --pick-mid: #2489be;
  --pick-line: #a6e6ff;
  --pick-glow: rgb(30 160 255 / 0.8);
  --empty: rgb(255 255 255 / 0.1);
  --empty-line: rgb(255 255 255 / 0.13);
  /* The tablet on the page. */
  --halo: rgb(70 190 255 / 0.32);
  --edge: rgb(255 255 255 / 0.04);
  --shade: rgb(16 22 40 / 0.28);
  --focus: #2f6fdf;
  position: relative;
  container-type: inline-size;
  isolation: isolate;
  display: grid;
  place-items: center;
  width: 100%;
  min-height: 400px;
  padding: 24px 0;
  box-sizing: border-box;
  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.04), rgb(255 255 255 / 0.1));
    --shade: light-dark(rgb(16 22 40 / 0.28), rgb(0 0 0 / 0.55));
    --halo: light-dark(rgb(70 190 255 / 0.32), rgb(70 190 255 / 0.22));
    --focus: light-dark(#2f6fdf, #8db4ff);
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

/* The tablet: the bezel's top and bottom bands, the screen between them, four carved corners. */
.tablet {
  --u: min(1px, 100cqi / 960);
  --w: 960;
  --bar-h: 42;
  --foot-h: 41;
  --screen-h: 457;
  --corner-w: 124;
  --name-top: 108;
  --name-fs: 26.5;
  --name-lh: 32;
  --row-top: 27;
  --tile-s: 65;
  --tile-gap: 7;
  --desc-top: 24.5;
  --head-fs: 19;
  --head-lh: 23;
  --div-w: 398;
  --text-w: 312;
  --text-fs: 12.4;
  --text-lh: 15.8;
  --tab-fs: 18.6;
  position: relative;
  display: grid;
  grid-template-rows: calc(var(--bar-h) * var(--u)) auto calc(var(--foot-h) * var(--u));
  width: calc(var(--w) * var(--u));
  max-width: 100%;
  border-radius: calc(5 * var(--u));
  background: linear-gradient(var(--bezel), var(--bezel-deep));
  box-shadow:
    0 0 0 1px var(--edge),
    0 0 calc(18 * var(--u)) var(--halo),
    0 calc(10 * var(--u)) calc(34 * var(--u)) var(--shade);
  animation: tabletIn 420ms cubic-bezier(0.2, 0.8, 0.3, 1);
}
@keyframes tabletIn {
  from {
    opacity: 0;
    scale: 0.97;
  }
}

/* The top band: a rail, the page's tab and the next page's shoulder prompt. */
.bar,
.foot {
  position: relative;
  overflow: hidden;
  border-radius: calc(5 * var(--u)) calc(5 * var(--u)) 0 0;
}
.foot {
  border-radius: 0 0 calc(5 * var(--u)) calc(5 * var(--u));
}
.rail {
  position: absolute;
  left: calc(var(--corner-w) * var(--u));
  right: calc(var(--corner-w) * var(--u));
  top: calc(2 * var(--u));
  width: auto;
  height: calc(18 * var(--u));
}
.foot .rail {
  top: auto;
  bottom: calc(4 * var(--u));
}
.tab,
.next {
  position: absolute;
  top: calc(30 * var(--u));
  translate: 0 -50%;
  color: var(--cyan);
  line-height: 1;
  white-space: nowrap;
  text-shadow: 0 0 calc(5 * var(--u)) var(--cyan-glow);
}
.tab {
  left: 50%;
  translate: -50% -50%;
  font-size: calc(var(--tab-fs) * var(--u));
  font-weight: 400;
}
.next {
  left: calc(50% + 109 * var(--u));
  display: flex;
  align-items: center;
  gap: calc(6 * var(--u));
  font-size: calc(16.5 * var(--u));
  font-style: italic;
  font-weight: 500;
}
.key {
  display: grid;
  place-items: center;
  width: calc(18 * var(--u));
  height: calc(15 * var(--u));
  border-radius: calc(3 * var(--u)) calc(6 * var(--u)) calc(3 * var(--u)) calc(3 * var(--u));
  background: var(--cyan);
  color: #08131d;
  font-size: calc(10.5 * var(--u));
  font-style: normal;
  font-weight: 700;
  text-shadow: none;
  box-shadow: 0 0 calc(5 * var(--u)) var(--cyan-glow);
}

/* The screen: navy, a faint dot grid, blue light seeping in along the bands. */
.screen {
  position: relative;
  display: grid;
  justify-items: center;
  align-content: start;
  height: calc(var(--screen-h) * var(--u));
  box-sizing: border-box;
  padding: calc(var(--name-top) * var(--u)) calc(16 * var(--u)) 0;
  background:
    linear-gradient(
      var(--edge-light),
      rgb(5 66 140 / 0.45) calc(8 * var(--u)),
      transparent calc(36 * var(--u))
    ),
    linear-gradient(
      to top,
      var(--edge-light),
      rgb(5 66 140 / 0.45) calc(8 * var(--u)),
      transparent calc(36 * var(--u))
    ),
    radial-gradient(
        circle,
        var(--navy-dot) 0 calc(1.1 * var(--u)),
        transparent calc(1.6 * var(--u))
      )
      center / calc(18 * var(--u)) calc(18 * var(--u)),
    radial-gradient(70% 60% at 50% 45%, #1d2039, var(--navy));
}

/* The name above the row, and the small flourish under it. */
.head {
  display: grid;
  justify-items: center;
  gap: calc(4 * var(--u));
}
.name {
  display: grid;
  margin: 0;
  font-size: max(16px, calc(var(--name-fs) * var(--u)));
  font-style: italic;
  font-weight: 500;
  line-height: max(20px, calc(var(--name-lh) * var(--u)));
  letter-spacing: -0.005em;
  color: var(--cyan);
  text-shadow:
    0 0 calc(4 * var(--u)) var(--cyan-glow),
    0 0 calc(12 * var(--u)) rgb(24 160 255 / 0.45);
}
.nameText {
  grid-area: 1 / 1;
  justify-self: center;
  white-space: nowrap;
}
.nameText[data-entering] {
  animation: nameIn 260ms cubic-bezier(0.2, 0.8, 0.3, 1) both;
}
.nameText[data-leaving] {
  animation: nameOut 160ms ease-in both;
}
@keyframes nameIn {
  from {
    opacity: 0;
    translate: calc(var(--dir, 0) * 14 * var(--u)) 0;
  }
}
@keyframes nameOut {
  to {
    opacity: 0;
  }
}
.flourish {
  width: calc(85 * var(--u));
  height: calc(7.4 * var(--u));
  overflow: visible;
}
.flourish path:first-child {
  fill: none;
  stroke: var(--cyan);
  stroke-width: 1.2;
  opacity: 0.55;
}
.flourish path,
.flourish circle {
  fill: var(--cyan);
  opacity: 0.8;
}

/* The row of tiles, and the highlight that glides under them. */
.row {
  position: relative;
  display: flex;
  gap: calc(var(--tile-gap) * var(--u));
  margin-top: calc(var(--row-top) * var(--u));
}
.tile,
.empty,
.highlight {
  position: relative;
  width: calc(var(--tile-s) * var(--u));
  height: calc(var(--tile-s) * var(--u));
  box-sizing: border-box;
  flex: none;
}
.tile {
  display: grid;
  place-items: center;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
  animation: tileIn 360ms calc(var(--i) * 40ms + 80ms) cubic-bezier(0.2, 0.8, 0.3, 1) both;
}
@keyframes tileIn {
  from {
    opacity: 0;
    translate: 0 calc(8 * var(--u));
  }
}
/* A tile's dark plate: a slate edge, a hairline inside it, and a lit wedge in each corner. */
.tile::before,
.tile::after {
  content: '';
  position: absolute;
  inset: 0;
  transition: opacity 180ms;
}
.tile::before {
  border: 1px solid var(--tile-line);
  background:
    linear-gradient(var(--tile-inner), var(--tile-inner)) top calc(3 * var(--u)) left 50% /
      calc(100% - 6 * var(--u)) 1px no-repeat,
    linear-gradient(var(--tile-inner), var(--tile-inner)) bottom calc(3 * var(--u)) left 50% /
      calc(100% - 6 * var(--u)) 1px no-repeat,
    linear-gradient(var(--tile-inner), var(--tile-inner)) left calc(3 * var(--u)) top 50% / 1px
      calc(100% - 6 * var(--u)) no-repeat,
    linear-gradient(var(--tile-inner), var(--tile-inner)) right calc(3 * var(--u)) top 50% / 1px
      calc(100% - 6 * var(--u)) no-repeat,
    radial-gradient(75% 75% at 50% 45%, var(--tile-mid), var(--tile));
  box-shadow: 0 0 calc(3 * var(--u)) rgb(0 0 0 / 0.45);
}
.tile::after,
.highlightInner::after {
  --c: var(--tile-corner);
  --s: calc(6.5 * var(--u));
  background:
    linear-gradient(135deg, var(--c) 0 34%, #0a131b 34% 46%, transparent 46%) top left / var(--s)
      var(--s) no-repeat,
    linear-gradient(225deg, var(--c) 0 34%, #0a131b 34% 46%, transparent 46%) top right / var(--s)
      var(--s) no-repeat,
    linear-gradient(45deg, var(--c) 0 34%, #0a131b 34% 46%, transparent 46%) bottom left / var(--s)
      var(--s) no-repeat,
    linear-gradient(315deg, var(--c) 0 34%, #0a131b 34% 46%, transparent 46%) bottom right /
      var(--s) var(--s) no-repeat;
  opacity: 0.9;
}
.tile[data-selected]::before,
.tile[data-selected]::after {
  opacity: 0;
}
.tile:hover:not([data-selected])::before {
  border-color: #6f8ea3;
}
.tile:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: calc(4 * var(--u) + 2px);
}
.glyph {
  position: relative;
  z-index: 1;
  width: 74%;
  height: auto;
  aspect-ratio: 64 / 60;
  overflow: visible;
  transition: scale 260ms cubic-bezier(0.3, 1.6, 0.5, 1);
}
.tile[data-selected] .glyph {
  scale: 1.04;
}
/* The flash of a tile whose upgrade just landed. */
.flash {
  position: absolute;
  inset: calc(-3 * var(--u));
  z-index: 2;
  background: radial-gradient(
    closest-side,
    rgb(220 248 255 / 0.85),
    rgb(60 200 255 / 0.35) 70%,
    transparent
  );
  opacity: 0;
  pointer-events: none;
}

/* The highlight: blue, a bright cyan edge with a hairline inside, lit corners, a glow. */
.highlight {
  position: absolute;
  top: 0;
  left: 0;
  translate: calc(var(--sel) * (var(--tile-s) + var(--tile-gap)) * var(--u)) 0;
  transition: translate 280ms cubic-bezier(0.3, 1.25, 0.45, 1);
  pointer-events: none;
  animation: copyIn 300ms calc(var(--sel) * 40ms + 200ms) ease-out both;
}
.highlightInner {
  position: absolute;
  inset: 0;
  border: calc(2 * var(--u)) solid var(--pick-line);
  background: radial-gradient(80% 80% at 50% 45%, var(--pick-mid), var(--pick));
  box-shadow:
    0 0 calc(3 * var(--u)) rgb(190 240 255 / 0.9),
    0 0 calc(10 * var(--u)) calc(1 * var(--u)) var(--pick-glow),
    inset 0 0 calc(6 * var(--u)) rgb(150 225 255 / 0.45);
}
.highlightInner::before,
.highlightInner::after {
  content: '';
  position: absolute;
}
.highlightInner::before {
  inset: calc(1.5 * var(--u));
  border: 1px solid rgb(190 238 255 / 0.55);
}
.highlightInner::after {
  --c: #d7f5ff;
  --s: calc(7 * var(--u));
  inset: calc(-1 * var(--u));
}

/* A slot still to be earned. */
.empty {
  border: 1px solid var(--empty-line);
  background: var(--empty);
  box-shadow: inset 0 0 0 calc(3 * var(--u)) rgb(255 255 255 / 0.015);
  animation: tileIn 360ms calc(var(--i) * 40ms + 80ms) cubic-bezier(0.2, 0.8, 0.3, 1) both;
}

/* The description: a heading, a capped line, two or three lines with a red mark. */
.desc {
  display: grid;
  margin-top: calc(var(--desc-top) * var(--u));
}
.copy {
  grid-area: 1 / 1;
  display: grid;
  justify-items: center;
}
.copy[data-entering] {
  animation: copyIn 240ms 60ms ease-out both;
}
.copy[data-leaving] {
  animation: nameOut 140ms ease-in both;
}
@keyframes copyIn {
  from {
    opacity: 0;
  }
}
.heading {
  margin: 0;
  font-size: max(13px, calc(var(--head-fs) * var(--u)));
  font-style: italic;
  font-weight: 500;
  line-height: max(16px, calc(var(--head-lh) * var(--u)));
  color: var(--cyan);
  text-shadow:
    0 0 calc(4 * var(--u)) var(--cyan-glow),
    0 0 calc(10 * var(--u)) rgb(24 160 255 / 0.4);
  white-space: nowrap;
}
.divider {
  display: flex;
  align-items: center;
  width: calc(var(--div-w) * var(--u));
  max-width: 100%;
  height: calc(10 * var(--u));
  margin-top: calc(3.5 * var(--u));
  filter: drop-shadow(0 0 calc(2.5 * var(--u)) var(--cyan-glow));
}
.line {
  flex: 1;
  height: max(1px, calc(1.4 * var(--u)));
  margin: 0 calc(-1 * var(--u));
  background: var(--cyan);
}
.cap {
  flex: none;
  width: calc(24 * var(--u));
  height: calc(10 * var(--u));
  overflow: visible;
}
.cap:last-child {
  scale: -1 1;
}
.cap circle {
  fill: none;
  stroke: var(--cyan);
  stroke-width: 1.6;
}
.cap path:nth-child(2) {
  fill: none;
  stroke: var(--cyan);
  stroke-width: 1.4;
}
.cap path:nth-child(3) {
  fill: var(--cyan);
}
.text {
  width: max(calc(var(--text-w) * var(--u)), 277px);
  max-width: 100%;
  margin: calc(7 * var(--u)) 0 0;
  font-size: max(11px, calc(var(--text-fs) * var(--u)));
  font-style: italic;
  line-height: max(14px, calc(var(--text-lh) * var(--u)));
  color: var(--cyan);
  text-shadow: 0 0 calc(3 * var(--u)) rgb(24 160 255 / 0.45);
  text-wrap: pretty;
}
.mark {
  background: none;
  color: var(--mark);
  text-shadow: 0 0 calc(3 * var(--u)) rgb(240 72 74 / 0.4);
}

/* The four carved corners, over the bands and the screen's corners. */
.corner {
  position: absolute;
  z-index: 3;
  width: calc(var(--corner-w) * var(--u));
  height: calc(var(--corner-w) * 132 / 124 * var(--u));
  overflow: visible;
  pointer-events: none;
}
.corner[data-at='tl'] {
  top: 0;
  left: 0;
}
.corner[data-at='tr'] {
  top: 0;
  right: 0;
  scale: -1 1;
}
.corner[data-at='bl'] {
  bottom: 0;
  left: 0;
  scale: 1 -1;
}
.corner[data-at='br'] {
  bottom: 0;
  right: 0;
  scale: -1 -1;
}

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

/* The compact layout: a 520-unit tablet as tall as its content. */
@container (max-width: 699px) {
  .tablet {
    --u: min(1px, 100cqi / 520);
    --w: 520;
    --bar-h: 34;
    --foot-h: 30;
    --corner-w: 64;
    --name-top: 52;
    --name-fs: 25;
    --name-lh: 30;
    --row-top: 20;
    --tile-s: 52;
    --tile-gap: 6;
    --desc-top: 22;
    --head-fs: 19;
    --head-lh: 24;
    --div-w: 420;
    --text-w: 380;
    --text-fs: 15;
    --text-lh: 20;
    --tab-fs: 17;
  }
  .screen {
    height: auto;
    padding-bottom: calc(48 * var(--u));
  }
  .rail {
    height: calc(13 * var(--u));
  }
  .tab,
  .next {
    top: calc(24 * var(--u));
  }
  .next {
    left: calc(50% + 70 * var(--u));
    font-size: calc(15 * var(--u));
  }
  .text {
    width: min(100%, max(calc(var(--text-w) * var(--u)), 277px));
  }
}

@media (prefers-reduced-motion: reduce) {
  .tablet,
  .tile,
  .empty,
  .highlight,
  .nameText,
  .copy {
    animation: none !important;
  }
  .highlight,
  .glyph {
    transition: none;
  }
}
```