# Tablet frame

> The device frame around a game’s tablet screens: a navy screen with a glowing edge, a carved bronze bezel, L/R screen tabs and button hints, and a power-on sweep.

The frame every tablet screen in a game lives in: abilities, map, album. A deep navy screen with a faint engraved texture and a blue-cyan glow along its inner edge, held in dark flecked stone carved with bronze circuit traces, heaviest at the corners. The top bar names the screen with its neighbours on each side and their shoulder buttons; the bottom bar lists what the buttons do here. Opening the tablet runs a cyan glow round the screen’s edge before the content rises in. Press L and R (or Q and E) to change screen, or the letter of a hint. It brings its own dark device, so it reads on a light or a dark page, and it is drawn in SVG and CSS, so it stays sharp at any size.

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

Category: Games · Overlays.

## Anatomy

1. **Device**: The whole tablet: a three-row grid (top band, screen, bottom band) at 16 / 9, or 4 / 3 under 600 px. Every size is a multiple of --u, 1 px at 960 px wide.
2. **Corner carving**: Inline SVG over all four corners: bronze traces, rings and studs drawn as a relief on a blue bed; this one, the top right, runs 260 units down the side. Decorative and clipped to the device.
3. **Top band**: Dark flecked stone with a carved trace along its edge, 42 units tall (30 px at least); it holds the screen’s title and its neighbours in a `nav`.
4. **Previous screen**: The neighbour to the left with its L glyph, a real button; the next screen sits right with R. At either end that side is empty and pushing it bumps the bar.
5. **Title**: The current screen’s name, centred in cyan with a soft glow; it comes in from the side of travel when the screen changes.
6. **Next screen**: The neighbour to the right with its R glyph; R, E or PageDown and a click all ask `onTabChange` for it.
7. **Screen**: Navy with a faint engraved texture under a glowing inner edge that breathes while on; the content is a focusable `region` named by the title, keyed by screen so it slides in from the side of travel.
8. **Button hints**: The bottom band: what each button does here, right aligned. Each hint is a button; clicking it or pressing its key squeezes the glyph and calls its `onPress`.

## When to use

- The device around a game’s in-world tablet or handheld: the abilities, map and album pages the player opens from the pause button.
- A few full-screen pages read in a row and paged with the shoulder buttons, L and R (or Q and E), where the player should see which page is either side.
- Pages played with a controller: the footer says what each button does on this page, and every hint is also a real button for mouse and touch.
- A menu that should feel like switching a device on: the cyan sweep and the content rising in mark the moment the game pauses.
- Any game page that has to read on a light or a dark site: the bezel and screen bring their own dark, only the shadow follows the page.

## When not to use

- Moving between the sections of a pause menu with many tabs. Use menu tabs, which show every section at once instead of only the neighbours.
- A dialog or a confirmation over the game. Use a game modal; this frame is a whole screen, not a box over one.
- An overlay shown while playing (health, ammo, a map in the corner). Use the HUD pieces; the frame hides the game behind it.
- A device mock-up for a product page or an app screenshot. Use a plain phone or tablet frame; this one is a fantasy prop with carved bronze.
- Pages read with a mouse and a scroll wheel only. Use ordinary tabs and a page; the shoulder neighbours and button hints assume a controller.

## Installation

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

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

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

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

## Usage

```tsx
import { useState } from 'react';
import { TabletFrame, type TabletTab } from './tablet-frame';

const TABS: TabletTab[] = [
  { id: 'skills', label: 'Skills' },
  { id: 'map', label: 'Map' },
  { id: 'journal', label: 'Journal' },
];

export function Tablet({ onClose }: { onClose: () => void }) {
  const [tab, setTab] = useState('map');
  return (
    <TabletFrame
      title="Tablet"
      tabs={TABS}
      tab={tab}
      onTabChange={setTab}
      autoplay={false}
      footerHints={[
        { label: 'Back', button: 'B', onPress: onClose },
        { label: 'Select', button: 'A' },
      ]}
    >
      {(current) => <p>{current?.label} goes here.</p>}
    </TabletFrame>
  );
}
```

## States

- **First screen**: `defaultTab="abilities"` with `powered`: no neighbour on the left, Map with R on the right.
- **Middle screen**: `defaultTab="map"`: a neighbour on each side and four hints, the stick among them.
- **Last screen**: `defaultTab="album"`: no neighbour on the right; R or E bumps the bar instead of wrapping.
- **Powered off**: `powered={false}`: the words, neighbours and hints fade and the screen goes black between the bands. Turning it on plays the sweep.
- **One screen**: Your own children and a `title`, no `tabs`: the title is centred alone and the hints are yours.
- **No hints**: An empty `footerHints`: the bottom band stays, carved and empty.
- **Four by three**: `aspectRatio="4 / 3"` at full width: the screen grows taller, the bands keep their height.
- **Narrow**: At 420 px it turns 4 / 3 on its own; --u drops to 0.44 px and the text and bands stop at their floors.
- **On a dark page**: `colorScheme="dark"`: the device is the same; only the halo round it and the shadow under it change.

## How it works

- The device is a three-row grid: the top band, the screen and the bottom band, at 16 / 9 (4 / 3 under 600 px) and taller if its content needs it. Every size is a multiple of --u, 1 px when the frame is 960 px wide (clamped between 0.36 and 1.5 px); the bands, text and glyphs have floors so a phone stays readable.
- The carving is vector only. Each corner is an inline SVG whose traces, rings and studs are one geometry drawn seven times through <use>, as a relief: two faint wide blue strokes for the glow, a blue bed, a dark shadow nudged down and right, the bronze body on a gradient, a pale rim nudged up and left, and a dashed grain. The bands repeat a 176 × 22 tile of the same carving as an SVG background sized in --u, over a tile of pale flecks; the screen’s texture is a sparse engraved tile at a few percent.
- The inner glow is a stack of inset box-shadows on a layer over the content: a hairline at the top and bottom edges, a soft spill under each band (stronger at the bottom) and a faint vignette. Once on, it breathes between 100 and 80 percent over 5 s.
- Power-on runs when the frame is first seen and each time powered turns true; the layers involved are keyed by a boot counter so their keyframes replay. A cyan rim is revealed by two conic-gradient masks on a registered angle (@property --tf-sweep), opening from the top centre down both sides in 560 ms, then fades; a black layer lifts off the navy from 180 ms; the title, neighbours and content rise 8 px from about 520 ms; the hints follow 60 ms apart. Power off fades the words and content in 200 ms, then the screen darkens.
- Changing screen keys the labels and content by screen: the new title comes in from the side of travel at 0.8 scale with a small overshoot (320 ms), the neighbours and content slide 32–36 px, and the pressed shoulder glyph squeezes to 0.78 and springs back. Pushing past the first or last screen bumps the bar 7 px instead. A hint’s glyph does the same squeeze when its button is pressed.
- Without children it shows three sample screens (abilities, map, album) and a demo pages through them, presses hints, and powers off and on while it is on screen and untouched; any input hands it to the reader.

## API reference

- **title** (`string`): The frame’s accessible name, and the centred title when there are no tabs.
- **tabs** (`TabletTab[]`, default `Abilities, Map, Album (with the sample screens)`): The tablet’s screens in order, as { id, label }. The bar shows the current one and its neighbours; the ends do not wrap.
- **tab** (`string`): The current screen’s id, to control it from outside.
- **defaultTab** (`string`): The screen it opens on, when not controlled (the first by default).
- **onTabChange** (`(id: string) => void`): Called when L, R or a neighbour’s button asks for the next or previous screen.
- **footerHints** (`TabletHint[] | ((tab) => TabletHint[])`): The button hints, right aligned, as { label, button, onPress? }; button is A, B, X, Y, L, R, ZL, ZR, +, -, LS or RS. A function receives the current screen.
- **powered** (`boolean`, default `true`): On or off. Turning it on plays the power-on (it also plays when the frame is first seen); off fades the content and darkens the screen.
- **children** (`ReactNode | ((tab) => ReactNode)`): The screen’s content, or a function of the current screen. Without it, three sample screens show.
- **aspectRatio** (`string`): The frame’s aspect ratio, such as "4 / 3". By default 16 / 9, and 4 / 3 under 600 px; content taller than that grows the frame.
- **autoplay** (`boolean`, default `true`): While on screen and untouched (and powered is not set), a short demo: screens paged, hints pressed, a power cycle.
- **bootWhenSeen** (`boolean`, default `true`): Waits for the frame to be first seen before powering on. false powers it on as it mounts (for a frame drawn clipped or off screen) and turns the demo off.
- **colorScheme** (`'light' | 'dark' | 'system'`): Forces light or dark for the shadow and halo on the page; by default it follows the page. The device itself stays dark.
- **className** (`string`): Added to the root.

## Accessibility

- The device is a section named by title (or the current screen); the top bar is a nav whose neighbours are buttons named “Previous screen: Map” and “Next screen: Album”, with aria-keyshortcuts, and the current screen’s name is a heading.
- The screen is a focusable region named by the current screen, so the keys work even when the content has nothing focusable. When a neighbour button disappears at an end, focus moves to the screen instead of being lost.
- Keyboard as a controller: L, Q or Page Up and R, E or Page Down change screen; A, B, X, Y, + and - press the hint with that button (Escape presses B). Keys typed into a field inside the content are left alone.
- A polite live region says the new screen and its position (“Map, screen 2 of 3”).
- The bezel, the glows and the glyphs are aria-hidden; the hints are buttons named by their label, with the button in aria-keyshortcuts. Everything shows a :focus-visible ring.
- Reduced motion: no sweep, no rise, no slides, no bump, no breathing; power on and off and screen changes are immediate. The demo never plays.

## Source

### tablet-frame/tablet-frame.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useEffectEvent,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
  type ReactNode,
} from 'react';
import { BUTTON_NAME, ButtonGlyph, type TabletButton } from './glyphs';
import { Corner, TILES } from './ornament';
import { SAMPLE_HINTS, SAMPLE_TABS, SampleScreen } from './samples';
import { useAutoplay } from './use-autoplay';
import styles from './tablet-frame.module.css';

/*
 * Tablet frame: the device that wraps a game's tablet screens (abilities, map, album…). A deep
 * navy screen with a faint engraved texture and a glowing blue-cyan inner edge, held in a dark
 * stone bezel carved with bronze circuit traces, heaviest at the corners.
 *
 * - The top bar names the screen in the centre and its neighbours on each side with their
 *   shoulder buttons: the previous one left with L, the next one right with R. The ends do not
 *   wrap: at the first or last screen that side is empty, and pushing it bumps the bar.
 * - The bottom bar lists the button hints, right aligned: a label and its button's glyph. Each is
 *   a real button; pressing it (or its key) presses the glyph and calls its onPress.
 * - Power on (when the frame is first seen, and each time `powered` turns true): the screen is
 *   black, a cyan glow runs round the inner edge from the top centre to the bottom both ways (a
 *   conic mask on an animated angle), the navy lights up, then the title, neighbours and content
 *   rise in and the hints follow one by one. Power off fades them, then the screen goes dark.
 * - Changing screen slides the labels like a carousel, flashes the shoulder glyph and brings the
 *   content in from the side of travel (the content is keyed by screen, so it remounts).
 * - Sizes are multiples of --u, 1 px when the frame is 960 px wide (clamped between 0.36 and
 *   1.5 px), with floors on text and bars so a phone stays readable. The bezel and its glow are
 *   SVG and CSS only (no bitmap), so it is sharp at any size; the bezel and screen are dark on any
 *   page, only the shadow and focus ring follow the colour scheme.
 * - Without children it shows three sample screens, and a demo pages through them, presses a hint
 *   and powers off and on while it is on screen and untouched.
 */

export type { TabletButton };

export type TabletTab = { id: string; label: string };

export type TabletHint = {
  /** What the button does here. */
  label: string;
  /** Which button. */
  button: TabletButton;
  /** Called when the hint is clicked or its key pressed. */
  onPress?: () => void;
};

export type TabletFrameProps = {
  className?: string;
  /** The screen's name: the frame's accessible name, and the centred title when there are no tabs. */
  title?: string;
  /** The tablet's screens, in order. The bar shows the current one and its neighbours. */
  tabs?: TabletTab[];
  /** The current screen's id (controlled). */
  tab?: string;
  /** The screen it opens on (uncontrolled). */
  defaultTab?: string;
  /** Called when L or R (or a neighbour's button) asks for another screen. */
  onTabChange?: (id: string) => void;
  /** The button hints in the footer, or a function of the current screen. */
  footerHints?: TabletHint[] | ((tab: TabletTab | undefined) => TabletHint[]);
  /** On or off. Turning it on plays the power-on. Default true (it powers on when first seen). */
  powered?: boolean;
  /** The screen's content, or a function of the current screen. Without it, sample screens show. */
  children?: ReactNode | ((tab: TabletTab | undefined) => ReactNode);
  /** The frame's aspect ratio, e.g. '16 / 9'. By default 16 / 9, and 4 / 3 under 600 px. */
  aspectRatio?: string;
  /** Plays a short demo while on screen and untouched. */
  autoplay?: boolean;
  /**
   * Waits for the frame to be first seen before powering on. Default true; false powers on as it
   * mounts (for a frame drawn clipped or off screen) and turns the demo off.
   */
  bootWhenSeen?: boolean;
  colorScheme?: 'light' | 'dark' | 'system';
};

type Enter = 'boot' | 'next' | 'prev';

/** The demo: steps at ms from its start. */
type Step = {
  at: number;
  do: 'reset' | 'next' | 'prev' | 'beat' | 'off' | 'on' | TabletButton;
};
const DEMO: Step[] = [
  { at: 0, do: 'reset' },
  { at: 1700, do: 'beat' },
  { at: 2500, do: 'beat' },
  { at: 3300, do: 'A' },
  { at: 4300, do: 'next' },
  { at: 6200, do: 'Y' },
  { at: 7200, do: 'next' },
  { at: 8300, do: 'beat' },
  { at: 9000, do: 'beat' },
  { at: 9900, do: 'next' },
  { at: 11000, do: 'prev' },
  { at: 12300, do: 'B' },
  { at: 13200, do: 'off' },
  { at: 14400, do: 'on' },
];
const DEMO_LENGTH = 17200;

const PREV_KEYS = new Set(['l', 'q', 'PageUp']);
const NEXT_KEYS = new Set(['r', 'e', 'PageDown']);
const BUTTON_KEYS: Record<string, TabletButton> = {
  a: 'A',
  b: 'B',
  x: 'X',
  y: 'Y',
  '+': '+',
  '-': '-',
  Escape: 'B',
};

const editable = (target: EventTarget) =>
  target instanceof HTMLElement &&
  (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName));

export function TabletFrame({
  className,
  title,
  tabs: tabsProp,
  tab: tabProp,
  defaultTab,
  onTabChange,
  footerHints,
  powered,
  children,
  aspectRatio,
  autoplay = true,
  bootWhenSeen = true,
  colorScheme,
}: TabletFrameProps) {
  const id = useId();
  const { ref, take, visible, playing } = useAutoplay<HTMLDivElement>();
  const sample = children === undefined;
  const tabs = tabsProp ?? (sample ? SAMPLE_TABS : []);

  const [ownTab, setOwnTab] = useState(() => defaultTab ?? tabs[0]?.id);
  const currentId = tabProp ?? ownTab;
  const index = Math.max(
    0,
    tabs.findIndex((item) => item.id === currentId),
  );
  const current: TabletTab | undefined = tabs[index];
  const prev = index > 0 ? tabs[index - 1] : undefined;
  const next = index < tabs.length - 1 ? tabs[index + 1] : undefined;

  // Power: on when asked (or by the demo) and once the frame has been seen.
  const [seen, setSeen] = useState(!bootWhenSeen);
  const [demoPower, setDemoPower] = useState(true);
  useEffect(() => {
    if (visible) setSeen(true);
  }, [visible]);
  const on = (powered ?? demoPower) && seen;
  const [boot, setBoot] = useState(0);
  const [enter, setEnter] = useState<Enter>('boot');
  useEffect(() => {
    if (!on) return;
    setBoot((count) => count + 1);
    setEnter('boot');
  }, [on]);

  const [flash, setFlash] = useState<{ side: 'prev' | 'next'; n: number }>({ side: 'next', n: 0 });
  const [bump, setBump] = useState<{ dir: 1 | -1; n: number }>({ dir: 1, n: 0 });
  const [pressed, setPressed] = useState<{ button: TabletButton | null; n: number }>({
    button: null,
    n: 0,
  });
  const [beat, setBeat] = useState(0);
  const prevRef = useRef<HTMLButtonElement>(null);
  const nextRef = useRef<HTMLButtonElement>(null);
  const screenRef = useRef<HTMLDivElement>(null);
  const refocus = useRef(false);
  const [said, setSaid] = useState('');

  const hints: TabletHint[] =
    typeof footerHints === 'function'
      ? footerHints(current)
      : (footerHints ?? (sample && current ? (SAMPLE_HINTS[current.id] ?? []) : []));

  const go = useCallback(
    (dir: 1 | -1) => {
      if (!on) return;
      setFlash((now) => ({ side: dir > 0 ? 'next' : 'prev', n: now.n + 1 }));
      const target = tabs[index + dir];
      if (!target) {
        setBump((now) => ({ dir, n: now.n + 1 }));
        return;
      }
      setEnter(dir > 0 ? 'next' : 'prev');
      // A neighbour button that is about to go (the end reached) hands its focus to the screen.
      const leaving = dir > 0 ? !tabs[index + 2] : index - 2 < 0;
      if (leaving && document.activeElement === (dir > 0 ? nextRef : prevRef).current)
        refocus.current = true;
      if (tabProp === undefined) setOwnTab(target.id);
      onTabChange?.(target.id);
      setBeat(0);
      setSaid(`${target.label}, screen ${index + dir + 1} of ${tabs.length}`);
    },
    [on, tabs, index, tabProp, onTabChange],
  );

  const press = useCallback(
    (button: TabletButton) => {
      if (!on) return false;
      const hint = hints.find((item) => item.button === button);
      if (!hint) return false;
      setPressed((now) => ({ button, n: now.n + 1 }));
      hint.onPress?.();
      return true;
    },
    [on, hints],
  );

  // The demo, on its own clock; it starts over each time it is handed back.
  const step = useEffectEvent((item: Step) => {
    if (item.do === 'reset') {
      if (tabProp === undefined && tabs[0]) {
        if (ownTab !== tabs[0].id) setEnter('prev');
        setOwnTab(tabs[0].id);
      }
      setBeat(0);
      setDemoPower(true);
    } else if (item.do === 'next') go(1);
    else if (item.do === 'prev') go(-1);
    else if (item.do === 'beat') setBeat((count) => count + 1);
    else if (item.do === 'off') setDemoPower(false);
    else if (item.do === 'on') {
      if (tabProp === undefined && tabs[0]) setOwnTab(tabs[0].id);
      setBeat(0);
      setDemoPower(true);
    } else press(item.do);
  });
  const demo = playing && autoplay && bootWhenSeen && powered === undefined;
  useEffect(() => {
    if (!demo) return;
    const ids: number[] = [];
    const run = (from: number) => {
      for (const item of DEMO) ids.push(window.setTimeout(() => step(item), from + item.at));
      ids.push(window.setTimeout(() => run(0), from + DEMO_LENGTH));
    };
    run(0);
    return () => ids.forEach((timer) => window.clearTimeout(timer));
  }, [demo]);
  // Handed to the reader while off: it comes back on.
  useEffect(() => {
    if (!demo) setDemoPower(true);
  }, [demo]);

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    take();
    if (event.altKey || event.ctrlKey || event.metaKey || editable(event.target)) return;
    const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
    if (tabs.length > 1 && PREV_KEYS.has(key)) {
      event.preventDefault();
      go(-1);
    } else if (tabs.length > 1 && NEXT_KEYS.has(key)) {
      event.preventDefault();
      go(1);
    } else if (BUTTON_KEYS[key] && press(BUTTON_KEYS[key])) {
      event.preventDefault();
    }
  };

  useEffect(() => {
    if (!refocus.current) return;
    refocus.current = false;
    screenRef.current?.focus({ preventScroll: true });
  }, [current?.id]);

  const content = typeof children === 'function' ? children(current) : children;
  const heading = current?.label ?? title ?? '';
  const panelId = `${id}-screen`;

  return (
    <div
      ref={ref}
      className={`${styles.root} ${className ?? ''}`}
      style={TILES as CSSProperties}
      data-color-scheme={colorScheme}
      onPointerDown={take}
      onKeyDown={onKeyDown}
      data-part="root"
    >
      <section
        className={styles.device}
        aria-label={title ?? (heading || 'Tablet')}
        data-power={on ? 'on' : 'off'}
        data-cold={boot === 0 || undefined}
        style={aspectRatio ? { aspectRatio } : undefined}
        data-part="device"
      >
        <header className={styles.bar} data-part="tab-bar">
          <nav
            key={`b${bump.n}`}
            className={styles.barInner}
            data-bump={bump.n ? bump.dir : undefined}
            aria-label="Screens"
          >
            <span className={styles.side} data-side="prev">
              {prev && (
                <button
                  ref={prevRef}
                  type="button"
                  className={styles.neighbour}
                  aria-controls={panelId}
                  aria-keyshortcuts="L Q PageUp"
                  aria-label={`Previous screen: ${prev.label}`}
                  onClick={() => go(-1)}
                  data-part="neighbour"
                >
                  <span
                    key={`p${boot}-${current?.id}`}
                    className={styles.neighbourInner}
                    data-enter={enter}
                  >
                    <span className={styles.neighbourLabel}>{prev.label}</span>
                    <span
                      key={flash.side === 'prev' ? flash.n : 0}
                      className={styles.glyph}
                      data-flash={(flash.side === 'prev' && flash.n > 0) || undefined}
                    >
                      <ButtonGlyph button="L" />
                    </span>
                  </span>
                </button>
              )}
            </span>
            <h2
              key={`t${boot}-${current?.id}`}
              className={styles.title}
              data-enter={enter}
              data-part="title"
            >
              {heading}
            </h2>
            <span className={styles.side} data-side="next">
              {next && (
                <button
                  ref={nextRef}
                  type="button"
                  className={styles.neighbour}
                  aria-controls={panelId}
                  aria-keyshortcuts="R E PageDown"
                  aria-label={`Next screen: ${next.label}`}
                  onClick={() => go(1)}
                  data-part="neighbour"
                >
                  <span
                    key={`n${boot}-${current?.id}`}
                    className={styles.neighbourInner}
                    data-enter={enter}
                  >
                    <span
                      key={flash.side === 'next' ? flash.n : 0}
                      className={styles.glyph}
                      data-flash={(flash.side === 'next' && flash.n > 0) || undefined}
                    >
                      <ButtonGlyph button="R" />
                    </span>
                    <span className={styles.neighbourLabel}>{next.label}</span>
                  </span>
                </button>
              )}
            </span>
          </nav>
        </header>

        <div className={styles.screen} data-part="screen">
          <span className={styles.texture} aria-hidden="true" />
          <div
            ref={screenRef}
            id={panelId}
            className={styles.content}
            role="region"
            aria-label={heading || undefined}
            tabIndex={0}
            data-part="content"
          >
            <div key={`c${boot}-${current?.id}`} className={styles.contentInner} data-enter={enter}>
              {sample ? (
                <SampleScreen
                  tab={current?.id ?? ''}
                  beat={beat}
                  onPick={(at) => {
                    take();
                    setBeat(at);
                  }}
                />
              ) : (
                content
              )}
            </div>
          </div>
          <span className={styles.edge} aria-hidden="true" data-part="edge-glow" />
          <span className={styles.blackout} key={`k${boot}`} aria-hidden="true" />
          <span className={styles.sweep} key={`s${boot}`} aria-hidden="true" data-part="sweep" />
        </div>

        <footer className={styles.foot} data-part="hints">
          <div key={`h${boot}-${current?.id}`} className={styles.hints}>
            {hints.map((hint, at) => (
              <button
                key={`${hint.button}-${hint.label}`}
                type="button"
                className={styles.hint}
                style={{ '--i': at } as CSSProperties}
                aria-keyshortcuts={BUTTON_NAME[hint.button]}
                onClick={() => press(hint.button)}
                data-part="hint"
              >
                <span>{hint.label}</span>
                <span
                  key={pressed.button === hint.button ? pressed.n : 0}
                  className={styles.glyph}
                  data-flash={(pressed.button === hint.button && pressed.n > 0) || undefined}
                >
                  <ButtonGlyph button={hint.button} />
                </span>
              </button>
            ))}
          </div>
        </footer>

        <div className={styles.corners} aria-hidden="true">
          <Corner name="tl" className={styles.corner} />
          <Corner name="tr" className={styles.corner} />
          <Corner name="br" className={styles.corner} />
          <Corner name="bl" className={styles.corner} />
        </div>
      </section>
      <p className={styles.live} aria-live="polite">
        {said}
      </p>
    </div>
  );
}
```

### tablet-frame/ornament.tsx

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

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

/*
 * The bezel's carved ornament, drawn as engraved circuit traces: tubes that run straight, step at
 * 45 degrees, bend round in small rounded notches and end in ring pads or round studs.
 *
 * Every shape is a stroke on one shared geometry, stacked like a relief: two wide, faint blue
 * strokes for the glow round the carving, a blue bed, a dark shadow pushed down and right, the
 * bronze body, a pale rim pushed up and left (the light catching the top edge) and a dashed dark
 * grain. No filter and no bitmap: it is all vector, so it stays sharp at any size. A pad is a
 * point-sized circle whose round stroke makes a stud; a ring is a circle stroked as a tube.
 *
 * The four corners are drawn apart (the bezel is heaviest at the top: a broad piece top left, a
 * tall one running down the right side, a small one bottom right and low arches bottom left), each
 * in a box measured in --u, so the CSS places and sizes them. The bands between them repeat a
 * tile of the same carving (bandTile), set as a background image sized in --u.
 */

type CornerArt = {
  /** The viewBox's width and height, in --u. */
  w: number;
  h: number;
  /** The blue bed under the carving, filled. */
  bed: string;
  /** The traces, as path data. */
  traces: string[];
  /** Rings: [cx, cy, r]. */
  rings: [number, number, number][];
  /** Round studs: [cx, cy]. */
  studs: [number, number][];
};

export type CornerName = 'tl' | 'tr' | 'br' | 'bl';

export const CORNERS: Record<CornerName, CornerArt> = {
  tl: {
    w: 160,
    h: 160,
    bed: 'M0 0H138C130 18 114 32 94 36L36 94C32 114 18 130 0 138Z',
    traces: [
      'M160 11H130A8 8 0 0 1 114 11H62L11 62V128A7 7 0 0 1 11 142V150',
      'M112 28H69L28 69V114',
      'M84 45H76L45 76V90',
    ],
    rings: [
      [11, 156, 4],
      [36, 36, 9],
      [120, 28, 7],
      [28, 122, 7],
      [91, 45, 5],
      [45, 97, 5],
    ],
    studs: [[36, 36]],
  },
  tr: {
    w: 128,
    h: 260,
    bed: 'M128 0H40C56 8 62 22 66 34C74 56 90 62 98 80C104 96 106 130 108 160C110 200 110 230 128 256Z',
    traces: [
      'M58 23L46 11H0',
      'M104 58L117 71V130A8 8 0 0 0 117 146V200L105 212V236',
      'M84 66V96L100 112V156',
      'M60 58L48 70V96',
    ],
    rings: [
      [80, 40, 24],
      [80, 40, 9],
      [105, 245, 7],
      [100, 165, 7],
      [48, 104, 6],
    ],
    studs: [[80, 40]],
  },
  br: {
    w: 100,
    h: 140,
    bed: 'M100 140H0C16 138 28 130 36 118C42 100 54 88 70 84C76 66 76 40 78 22C80 10 88 2 100 0Z',
    traces: ['M78 92L87 83V52A8 8 0 0 1 87 36V20', 'M47 112L30 129H0', 'M52 88L42 78V64'],
    rings: [
      [62, 104, 16],
      [87, 13, 6],
      [42, 57, 5],
    ],
    studs: [[62, 104]],
  },
  bl: {
    w: 184,
    h: 72,
    bed: 'M0 72V8C14 2 28 6 34 16C40 10 56 10 62 22C70 22 84 26 96 40C104 52 140 54 184 56V72Z',
    traces: ['M36 72V34A11 11 0 0 1 58 34V50', 'M75 72V44A10 10 0 0 1 95 44V61H184'],
    rings: [
      [16, 46, 10],
      [58, 57, 5],
    ],
    studs: [[16, 46]],
  },
};

/** The relief's layers, from the bottom up. */
function Relief({ id, width }: { id: string; width: number }) {
  const use = (props: SVGProps<SVGUseElement>) => <use href={`#${id}`} fill="none" {...props} />;
  return (
    <>
      {use({ stroke: 'var(--bezel-glow)', strokeWidth: width * 2.5, strokeOpacity: 0.16 })}
      {use({ stroke: 'var(--bezel-glow)', strokeWidth: width * 1.9, strokeOpacity: 0.3 })}
      {use({ stroke: 'var(--bezel-bed)', strokeWidth: width * 1.45 })}
      {use({
        stroke: 'var(--bronze-shadow)',
        strokeWidth: width * 1.16,
        strokeOpacity: 0.9,
        transform: `translate(${width * 0.1} ${width * 0.17})`,
      })}
      {use({ stroke: `url(#${id}-bronze)`, strokeWidth: width })}
      {use({
        stroke: 'var(--bronze-rim)',
        strokeWidth: width * 0.2,
        strokeOpacity: 0.7,
        transform: `translate(${-width * 0.15} ${-width * 0.17})`,
      })}
      {use({
        stroke: 'var(--bronze-grain)',
        strokeWidth: width * 0.66,
        strokeDasharray: `${width * 0.06} ${width * 0.22}`,
        strokeOpacity: 0.4,
      })}
      {use({
        stroke: 'var(--bronze-grain)',
        strokeWidth: width * 0.18,
        strokeOpacity: 0.55,
        transform: `translate(${width * 0.12} ${width * 0.16})`,
      })}
    </>
  );
}

export function Corner({ name, className }: { name: CornerName; className?: string }) {
  const id = useId().replace(/:/g, '');
  const art = CORNERS[name];
  return (
    <svg
      className={className}
      viewBox={`0 0 ${art.w} ${art.h}`}
      aria-hidden="true"
      data-corner={name}
      data-part="corner"
    >
      <defs>
        <linearGradient
          id={`${id}-bronze`}
          gradientUnits="userSpaceOnUse"
          x1="0"
          y1="0"
          x2={art.w}
          y2={art.h}
        >
          <stop offset="0" stopColor="var(--bronze-light)" />
          <stop offset="0.55" stopColor="var(--bronze)" />
          <stop offset="1" stopColor="var(--bronze-deep)" />
        </linearGradient>
        <g id={id} strokeLinecap="round" strokeLinejoin="round">
          {art.traces.map((d) => (
            <path key={d} d={d} />
          ))}
          {art.rings.map(([cx, cy, r]) => (
            <circle key={`${cx}-${cy}-${r}`} cx={cx} cy={cy} r={r} />
          ))}
          {art.studs.map(([cx, cy]) => (
            <circle key={`s${cx}-${cy}`} cx={cx} cy={cy} r="0.8" />
          ))}
        </g>
      </defs>
      <path d={art.bed} fill="var(--bezel-bed)" opacity="0.92" />
      <Relief id={id} width={12.5} />
    </svg>
  );
}

/*
 * The band tile: one 176 × 22 stretch of carving whose main trace enters and leaves at the same
 * height, so tiles join into one line. The colours are fixed (the bezel is the same in both
 * schemes), so it can be an image.
 */
const BAND_TRACE =
  'M-4 11H18A6 6 0 0 1 30 11H46L53 4H82L89 11H104V15A4 4 0 0 0 108 19H128A4 4 0 0 0 132 15V11H180';
const BAND_STUDS = [
  [118, 4.5],
  [156, 18.5],
];

function bandSvg(flip: boolean) {
  const w = 8.4;
  const studs = BAND_STUDS.map(([x, y]) => `<circle cx="${x}" cy="${y}" r="1.4"/>`).join('');
  const geometry = `<g id="t" stroke-linecap="round" stroke-linejoin="round"><path d="${BAND_TRACE}"/>${studs}</g>`;
  const layer = (attrs: string) => `<use href="#t" fill="none" ${attrs}/>`;
  return (
    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 176 22" preserveAspectRatio="none">` +
    `<defs>${geometry}<linearGradient id="b" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#d2b07a"/><stop offset="0.6" stop-color="#a7834f"/><stop offset="1" stop-color="#6f5028"/></linearGradient></defs>` +
    `<g${flip ? ' transform="translate(0 22) scale(1 -1)"' : ''}>` +
    layer(`stroke="#2a7dff" stroke-opacity="0.16" stroke-width="${w * 2.6}"`) +
    layer(`stroke="#0b0805" stroke-width="${w * 1.2}" transform="translate(0.6 1)"`) +
    layer(`stroke="url(#b)" stroke-width="${w}"`) +
    layer(
      `stroke="#f1d9a6" stroke-opacity="0.65" stroke-width="${w * 0.2}" transform="translate(-0.8 -0.95)"`,
    ) +
    layer(
      `stroke="#3e2b13" stroke-opacity="0.4" stroke-width="${w * 0.66}" stroke-dasharray="0.35 1.25"`,
    ) +
    `</g></svg>`
  );
}

/*
 * The stone under the band: a dark plate flecked with pale grains, as a small tile drawn at the
 * page's pixel size (the flecks are dots, so they stay crisp at any size).
 */
function speckleSvg() {
  const dots: string[] = [];
  // A fixed scatter: the same grains every time, so server and client agree.
  let seed = 7;
  const next = () => {
    seed = (seed * 9301 + 49297) % 233280;
    return seed / 233280;
  };
  for (let i = 0; i < 46; i++) {
    const x = (next() * 64).toFixed(1);
    const y = (next() * 64).toFixed(1);
    const r = (0.35 + next() * 0.55).toFixed(2);
    const o = (0.12 + next() * 0.32).toFixed(2);
    dots.push(`<circle cx="${x}" cy="${y}" r="${r}" fill="#d8dde6" fill-opacity="${o}"/>`);
  }
  return `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">${dots.join('')}</svg>`;
}

/*
 * The screen's own faint texture: a sparse engraved grid of traces and pads, at a few percent, so
 * the navy has a surface without competing with what is on it.
 */
function screenSvg() {
  return (
    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96">` +
    `<g fill="none" stroke="#7fb4ff" stroke-width="0.9" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.07">` +
    `<path d="M0 24H30L38 16H62M62 16A4 4 0 1 1 70 16A4 4 0 1 1 62 16M70 16H96"/>` +
    `<path d="M24 0V10M24 38V62L32 70V96"/>` +
    `<path d="M0 72H12A5 5 0 0 1 22 72H24M40 72H58L66 80H96"/>` +
    `<path d="M72 0V8M72 32V56A6 6 0 0 0 72 68"/>` +
    `<path d="M24 38A4 4 0 1 1 24 30A4 4 0 1 1 24 38"/>` +
    `<path d="M48 40H58V50H48Z"/>` +
    `</g>` +
    `<g fill="#7fb4ff" fill-opacity="0.08"><circle cx="24" cy="10" r="1.6"/><circle cx="72" cy="8" r="1.6"/><circle cx="40" cy="72" r="1.6"/><circle cx="53" cy="45" r="1.1"/></g>` +
    `</svg>`
  );
}

const url = (svg: string) => `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;

/** The tile images, as custom properties for the root's style. */
export const TILES = {
  '--tile-band-top': url(bandSvg(false)),
  '--tile-band-bottom': url(bandSvg(true)),
  '--tile-speckle': url(speckleSvg()),
  '--tile-screen': url(screenSvg()),
} as const;
```

### tablet-frame/glyphs.tsx

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

/*
 * The controller glyphs the tablet prints in its bars: shoulder buttons as a rounded square with
 * the letter cut out of it, face and system buttons as a disc, sticks as a disc with a ring and a
 * small cap. All are drawn in currentColor with the letter in var(--glyph-ink), so the bar decides
 * their colour; they are decorative (the buttons they sit in carry the name).
 */

export type TabletButton =
  'A' | 'B' | 'X' | 'Y' | 'L' | 'R' | 'ZL' | 'ZR' | '+' | '-' | 'LS' | 'RS';

const SQUARE = new Set<TabletButton>(['L', 'R', 'ZL', 'ZR']);

export function ButtonGlyph({ button }: { button: TabletButton }) {
  if (SQUARE.has(button)) {
    const small = button.length === 2;
    return (
      <svg viewBox="0 0 37 30" aria-hidden="true" data-shape="square">
        <rect x="0.5" y="0.5" width="36" height="29" rx="5.5" fill="currentColor" />
        <text
          x="18.5"
          y={small ? 20.4 : 21.4}
          textAnchor="middle"
          fontSize={small ? 14 : 17}
          fontWeight="700"
          fill="var(--glyph-ink)"
        >
          {button}
        </text>
      </svg>
    );
  }
  if (button === 'LS' || button === 'RS') {
    return (
      <svg viewBox="0 0 32 32" aria-hidden="true" data-shape="stick">
        <circle cx="16" cy="16" r="15" fill="currentColor" />
        <circle cx="16" cy="16" r="10.6" fill="none" stroke="var(--glyph-ink)" strokeWidth="1.8" />
        <text
          x="16"
          y="21.4"
          textAnchor="middle"
          fontSize="14.5"
          fontWeight="700"
          fill="var(--glyph-ink)"
        >
          {button[0]}
        </text>
        <path d="M12 2.6 16 0.4 20 2.6Z M12 29.4 16 31.6 20 29.4Z" fill="var(--glyph-ink)" />
      </svg>
    );
  }
  const sign = button === '+' || button === '-';
  return (
    <svg viewBox="0 0 32 32" aria-hidden="true" data-shape="disc">
      <circle cx="16" cy="16" r="15" fill="currentColor" />
      {sign ? (
        <path
          d={button === '+' ? 'M10 16H22M16 10V22' : 'M10 16H22'}
          stroke="var(--glyph-ink)"
          strokeWidth="3.4"
          strokeLinecap="round"
        />
      ) : (
        <text
          x="16"
          y="22.4"
          textAnchor="middle"
          fontSize="17.5"
          fontWeight="700"
          fill="var(--glyph-ink)"
        >
          {button}
        </text>
      )}
    </svg>
  );
}

/** How a button is read out, for aria-keyshortcuts and labels. */
export const BUTTON_NAME: Record<TabletButton, string> = {
  A: 'A',
  B: 'B',
  X: 'X',
  Y: 'Y',
  L: 'L',
  R: 'R',
  ZL: 'ZL',
  ZR: 'ZR',
  '+': 'Plus',
  '-': 'Minus',
  LS: 'Left stick',
  RS: 'Right stick',
};
```

### tablet-frame/samples.tsx

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

'use client';

import type { CSSProperties } from 'react';
import type { TabletButton } from './glyphs';
import styles from './samples.module.css';

/*
 * The sample screens the frame shows when it is given no children: three of a tablet's pages, in
 * flat original art, so the frame is seen doing its job. An abilities page (a row of slots, one
 * picked, its name and what it does), a map page (a faint grid, a route, the player's arrow and
 * two pins) and an album page (a grid of abstract snapshots, one framed). `beat` moves the
 * selection on the abilities and album pages, for the demo; a click picks too.
 */

export type SampleTab = { id: string; label: string };

export const SAMPLE_TABS: SampleTab[] = [
  { id: 'abilities', label: 'Abilities' },
  { id: 'map', label: 'Map' },
  { id: 'album', label: 'Album' },
];

export const SAMPLE_HINTS: Record<string, { label: string; button: TabletButton }[]> = {
  abilities: [
    { label: 'Back', button: 'B' },
    { label: 'Equip', button: 'A' },
  ],
  map: [
    { label: 'Move', button: 'LS' },
    { label: 'Pin', button: 'Y' },
    { label: 'Back', button: 'B' },
    { label: 'Select', button: 'A' },
  ],
  album: [
    { label: 'Delete', button: 'X' },
    { label: 'Back', button: 'B' },
    { label: 'View', button: 'A' },
  ],
};

const ABILITIES = [
  {
    name: 'Spark Charge',
    line: 'Set a charge',
    text: 'Place a charge that bursts on command.',
    hue: '#43c6ff',
  },
  {
    name: 'Lodestar',
    line: 'Pull metal',
    text: 'Grab and swing anything made of metal.',
    hue: '#ff5a76',
  },
  {
    name: 'Halt',
    line: 'Hold an object in time',
    text: 'Freeze one object; strike it to store force.',
    hue: '#ffd84a',
  },
  {
    name: 'Frostwell',
    line: 'Raise ice',
    text: 'Lift a pillar of ice out of still water.',
    hue: '#bfe9ff',
  },
  {
    name: 'Lens',
    line: 'Take a picture',
    text: 'Capture what you see and keep it in the album.',
    hue: '#b6e24a',
  },
  {
    name: 'Glider Call',
    line: 'Summon the glider',
    text: 'Unfold the glider from any height above the ground.',
    hue: '#ffad3a',
  },
];

function AbilityIcon({ index }: { index: number }) {
  const shapes = [
    // A charge: a disc with a spark.
    <g key="0">
      <circle cx="15" cy="17" r="9" fill="currentColor" />
      <path
        d="M21 9L25 5M23 12L28 11M19 7L19 2"
        stroke="currentColor"
        strokeWidth="2.2"
        strokeLinecap="round"
      />
    </g>,
    // A pull: a horseshoe.
    <path key="1" d="M8 6V16A8 8 0 0 0 24 16V6H19V16A3 3 0 0 1 13 16V6Z" fill="currentColor" />,
    // A hold: a ring with a stopped hand.
    <g key="2">
      <circle cx="16" cy="16" r="10.5" fill="none" stroke="currentColor" strokeWidth="3.4" />
      <path
        d="M16 9V16L20.5 19"
        stroke="currentColor"
        strokeWidth="2.8"
        strokeLinecap="round"
        fill="none"
      />
    </g>,
    // Ice: a six-armed star.
    <g key="3" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round">
      <path d="M16 4V28M5.6 10L26.4 22M5.6 22L26.4 10" />
      <path d="M12.5 6.5L16 9L19.5 6.5M12.5 25.5L16 23L19.5 25.5" fill="none" />
    </g>,
    // A lens: a body with an eye.
    <g key="4">
      <path d="M5 10H10L12 7H20L22 10H27V25H5Z" fill="currentColor" />
      <circle cx="16" cy="17" r="4.6" fill="var(--slot)" />
    </g>,
    // A glider: a wing over a line.
    <g key="5">
      <path d="M3 15Q16 3 29 15Q16 10 3 15Z" fill="currentColor" />
      <path d="M16 12V27" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" />
    </g>,
  ];
  return (
    <svg viewBox="0 0 32 32" aria-hidden="true">
      {shapes[index]}
    </svg>
  );
}

function Abilities({ beat, onPick }: { beat: number; onPick: (at: number) => void }) {
  const at = ((beat % ABILITIES.length) + ABILITIES.length) % ABILITIES.length;
  const ability = ABILITIES[at];
  return (
    <div className={styles.abilities} data-part="sample-abilities">
      <p className={styles.abilityName} key={`n${at}`}>
        {ability.name}
      </p>
      <span className={styles.ticks} aria-hidden="true" />
      <div className={styles.slots} role="listbox" aria-label="Abilities">
        {ABILITIES.map((item, index) => (
          <button
            key={item.name}
            type="button"
            role="option"
            aria-selected={index === at}
            aria-label={item.name}
            className={styles.slot}
            style={{ '--hue': item.hue } as CSSProperties}
            onClick={() => onPick(index)}
          >
            <AbilityIcon index={index} />
          </button>
        ))}
        <span className={styles.slot} data-empty aria-hidden="true" />
      </div>
      <p className={styles.abilityLine} key={`l${at}`}>
        {ability.line}
      </p>
      <span className={styles.rule} aria-hidden="true" />
      <p className={styles.abilityText} key={`t${at}`}>
        {ability.text}
      </p>
    </div>
  );
}

function MapPage() {
  return (
    <div className={styles.map} data-part="sample-map">
      <svg className={styles.route} viewBox="0 0 400 200" aria-hidden="true">
        <path
          d="M70 160C110 150 120 110 170 104C220 98 236 70 262 58C290 46 318 52 336 40"
          fill="none"
          stroke="#ffd34d"
          strokeWidth="2.2"
          strokeDasharray="1 7"
          strokeLinecap="round"
        />
        <path d="M64 166L78 150L82 168L72 162Z" fill="#ffd34d" />
        <g fill="#3ec8ff">
          <path d="M262 50L268 58L262 66L256 58Z" />
          <path d="M150 126L155 132L150 138L145 132Z" />
        </g>
        <circle cx="336" cy="40" r="5" fill="none" stroke="#ffd34d" strokeWidth="2" />
        <circle
          cx="336"
          cy="40"
          r="10"
          fill="none"
          stroke="#ffd34d"
          strokeOpacity="0.4"
          strokeWidth="1.4"
        />
      </svg>
      <p className={styles.mapLabel}>Follow the beacon</p>
      <dl className={styles.mapInfo}>
        <div>
          <dt>Elevation</dt>
          <dd>0128</dd>
        </div>
        <div>
          <dt>Time</dt>
          <dd>21:40</dd>
        </div>
      </dl>
    </div>
  );
}

const SNAPSHOTS = [
  'linear-gradient(180deg, #ffb86b 0 46%, #e46a5c 46% 62%, #5b2e5f 62%)',
  'radial-gradient(circle at 70% 36%, #fff3c4 0 12%, transparent 13%), linear-gradient(180deg, #5fb7e8, #2e6fb0)',
  'linear-gradient(135deg, #7ad08f 0 50%, #3f9a6a 50%)',
  'radial-gradient(circle at 30% 60%, #c8f0ff 0 18%, transparent 19%), linear-gradient(180deg, #1f3c78, #10203f)',
  'linear-gradient(90deg, #f2d06b 0 33%, #e09b4b 33% 66%, #b45a3c 66%)',
  'radial-gradient(ellipse at 50% 110%, #9be37e 0 40%, transparent 41%), linear-gradient(180deg, #a7dcf5, #6cb6e2)',
  'linear-gradient(160deg, #3a2a6b 0 40%, #7a4fa8 40% 70%, #d48ad8 70%)',
  'radial-gradient(circle at 50% 50%, #ffe08a 0 22%, #f09a4a 23% 40%, #6b3a2a 41%)',
];

function Album({ beat, onPick }: { beat: number; onPick: (at: number) => void }) {
  const at = ((beat % SNAPSHOTS.length) + SNAPSHOTS.length) % SNAPSHOTS.length;
  return (
    <div className={styles.album} data-part="sample-album">
      <div className={styles.shots} role="listbox" aria-label="Snapshots">
        {SNAPSHOTS.map((art, index) => (
          <button
            key={index}
            type="button"
            role="option"
            aria-selected={index === at}
            aria-label={`Snapshot ${String(index + 1).padStart(2, '0')}`}
            className={styles.shot}
            onClick={() => onPick(index)}
          >
            <span className={styles.art} style={{ background: art }} />
            <span className={styles.number}>{String(index + 1).padStart(2, '0')}</span>
          </button>
        ))}
      </div>
    </div>
  );
}

export function SampleScreen({
  tab,
  beat,
  onPick,
}: {
  tab: string;
  beat: number;
  onPick: (at: number) => void;
}) {
  if (tab === 'map') return <MapPage />;
  if (tab === 'album') return <Album beat={beat} onPick={onPick} />;
  return <Abilities beat={beat} onPick={onPick} />;
}
```

### tablet-frame/tablet-frame.module.css

```css
/* Tablet frame (tablet-frame.tsx): a game tablet's device frame, centred on a plain stage.
   Every size is a multiple of --u, 1 px when the frame is 960 px wide; text and the bars have a
   floor so a phone stays readable.

   The bezel, the screen and their glow are dark on any page, as a game draws them; only the
   shadow on the page and the focus ring follow the colour scheme, through light-dark() tokens.
   The ornament is inline SVG (the corners) and SVG tiles (the bands, the stone, the screen's
   texture) sized in --u, so nothing is a bitmap and nothing blurs.

   Motion: the power-on is keyframes on elements keyed by each boot (the sweep, the blackout, the
   bars' labels, the content, the hints); a screen change replays the labels' and content's
   keyframes from the side of travel; power off is transitions. */

@property --tf-sweep {
  syntax: '<angle>';
  inherits: false;
  initial-value: 180deg;
}

.root {
  /* The screen. */
  --navy: #17213d;
  --navy-deep: #0d1429;
  --slot: #0b1424;
  /* The glow on the screen's edge, the power-on sweep and the halo round the frame. */
  --edge: #2f8bff;
  --edge-core: #49b9ff;
  --glow: rgb(34 128 255 / 0.55);
  --sweep: #b4f7ff;
  --halo: rgb(98 220 240 / 0.34);
  /* The bands and the carving. */
  --stone: #17181b;
  --bezel-bed: #1a5fd6;
  --bezel-glow: #3a95ff;
  --bronze-light: #d9b984;
  --bronze: #a8844f;
  --bronze-deep: #6c4e27;
  --bronze-shadow: #0d0905;
  --bronze-rim: #f4ddae;
  --bronze-grain: #3e2b13;
  /* Text and glyphs. */
  --cyan: #3fc6f7;
  --cyan-soft: #62d1fb;
  --cyan-dim: rgb(63 198 247 / 0.55);
  --cyan-haze: rgb(40 180 255 / 0.55);
  --text-soft: #a9dcf5;
  --glyph-ink: #08263b;
  /* On the page. */
  --shade: rgb(16 28 48 / 0.28);
  --focus: #ffffff;
  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;
  color: var(--cyan);
  font-family:
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --shade: light-dark(rgb(16 28 48 / 0.28), rgb(0 0 0 / 0.55));
    --halo: light-dark(rgb(98 220 240 / 0.34), rgb(70 190 255 / 0.3));
  }
}
.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: bars on top and bottom, the screen between, the corners over all. */
.device {
  --u: clamp(0.36px, 100cqi / 960, 1.5px);
  --band: max(30px, calc(42 * var(--u)));
  position: relative;
  display: grid;
  grid-template-rows: var(--band) minmax(0, 1fr) var(--band);
  width: 100%;
  aspect-ratio: 16 / 9;
  border-radius: calc(5 * var(--u));
  background: var(--navy-deep);
  box-shadow:
    0 0 0 max(1px, calc(1.5 * var(--u))) #07090d,
    0 0 calc(30 * var(--u)) var(--halo),
    0 calc(12 * var(--u)) calc(34 * var(--u)) var(--shade);
}
@container (max-width: 599px) {
  .device {
    aspect-ratio: 4 / 3;
  }
}

/* The bands: dark flecked stone with a carved trace along the outer edge. */
.bar,
.foot {
  position: relative;
  z-index: 2;
  display: flex;
  min-width: 0;
  background-color: var(--stone);
  background-repeat: repeat-x, repeat;
  background-size:
    calc(176 * var(--u)) calc(22 * var(--u)),
    64px 64px;
}
.bar {
  align-items: flex-end;
  padding: 0 calc(124 * var(--u)) calc(4 * var(--u));
  border-radius: calc(5 * var(--u)) calc(5 * var(--u)) 0 0;
  background-image: var(--tile-band-top), var(--tile-speckle);
  background-position:
    0 0,
    0 0;
}
.foot {
  align-items: flex-start;
  justify-content: flex-end;
  padding: calc(7 * var(--u)) calc(88 * var(--u)) 0 calc(184 * var(--u));
  border-radius: 0 0 calc(5 * var(--u)) calc(5 * var(--u));
  background-image: var(--tile-band-bottom), var(--tile-speckle);
  background-position:
    0 100%,
    0 0;
}

/* The top bar: neighbour, title, neighbour. */
.barInner {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
  align-items: center;
  column-gap: calc(86 * var(--u));
  width: 100%;
  min-height: 62%;
}
.barInner[data-bump='1'] {
  animation: bump-next 300ms cubic-bezier(0.3, 0.7, 0.4, 1);
}
.barInner[data-bump='-1'] {
  animation: bump-prev 300ms cubic-bezier(0.3, 0.7, 0.4, 1);
}
.side {
  display: flex;
  min-width: 0;
}
.side[data-side='prev'] {
  justify-content: flex-end;
}
.title {
  margin: 0;
  font-size: max(14px, calc(17.5 * var(--u)));
  font-weight: 450;
  line-height: 1.1;
  letter-spacing: 0.005em;
  white-space: nowrap;
  color: var(--cyan);
  text-shadow: 0 0 calc(7 * var(--u)) var(--cyan-haze);
}
.neighbour {
  display: inline-flex;
  align-items: center;
  gap: calc(6 * var(--u));
  min-width: 0;
  margin: 0;
  padding: calc(2 * var(--u)) calc(4 * var(--u));
  border: 0;
  border-radius: calc(4 * var(--u));
  background: none;
  color: var(--cyan);
  font: inherit;
  cursor: pointer;
}
.neighbourInner {
  display: inline-flex;
  align-items: center;
  gap: inherit;
  min-width: 0;
}
.neighbourLabel {
  overflow: hidden;
  font-size: max(11px, calc(13 * var(--u)));
  font-style: italic;
  font-weight: 600;
  text-overflow: ellipsis;
  white-space: nowrap;
  text-shadow: 0 0 calc(6 * var(--u)) var(--cyan-haze);
}
.neighbour:hover .neighbourLabel {
  color: #a8ecff;
}

/* A glyph in a bar, with its own soft glow behind it. */
.glyph {
  display: inline-grid;
  flex: none;
  place-items: center;
  border-radius: 50%;
  color: var(--cyan);
  box-shadow: 0 0 calc(7 * var(--u)) var(--cyan-haze);
}
.glyph:has(svg[data-shape='square']) {
  border-radius: 18%;
}
.glyph svg {
  display: block;
  width: max(13px, calc(16.5 * var(--u)));
  height: max(13px, calc(16.5 * var(--u)));
}
.glyph svg[data-shape='square'] {
  width: max(15px, calc(18.5 * var(--u)));
  height: max(12px, calc(15 * var(--u)));
}
.glyph[data-flash] {
  animation: press 300ms cubic-bezier(0.3, 1.5, 0.5, 1);
}

/* The bottom bar: the button hints. */
.hints {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  align-items: center;
  column-gap: calc(26 * var(--u));
  row-gap: 2px;
  min-width: 0;
}
.hint {
  display: inline-flex;
  align-items: center;
  gap: calc(5 * var(--u));
  margin: 0;
  padding: calc(2 * var(--u)) calc(3 * var(--u));
  border: 0;
  border-radius: calc(4 * var(--u));
  background: none;
  color: var(--cyan);
  font: inherit;
  font-size: max(11px, calc(13.2 * var(--u)));
  font-weight: 500;
  line-height: 1.1;
  white-space: nowrap;
  text-shadow: 0 0 calc(6 * var(--u)) var(--cyan-haze);
  cursor: pointer;
}
.hint:hover > span:first-child {
  color: #a8ecff;
}

.neighbour:focus-visible,
.hint:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}

/* The screen: navy, the texture, the content, the glowing edge, and the power layers. */
.screen {
  position: relative;
  z-index: 1;
  display: grid;
  min-height: 0;
  background: radial-gradient(120% 90% at 50% 45%, var(--navy) 30%, var(--navy-deep) 100%);
}
.texture {
  position: absolute;
  inset: 0;
  background-image: var(--tile-screen);
  background-size: calc(132 * var(--u)) calc(132 * var(--u));
  background-position: center;
  pointer-events: none;
}
.content {
  position: relative;
  display: grid;
  min-width: 0;
  min-height: 0;
  padding: calc(18 * var(--u)) max(14px, calc(56 * var(--u)));
  overflow: hidden;
  outline: none;
}
.contentInner {
  height: 100%;
}
.content:focus-visible {
  box-shadow: inset 0 0 0 2px var(--focus);
}
.edge {
  position: absolute;
  inset: 0;
  pointer-events: none;
  box-shadow:
    inset 0 max(1px, calc(2 * var(--u))) 0 var(--edge),
    inset 0 calc(-2 * var(--u)) 0 var(--edge),
    inset 0 calc(14 * var(--u)) calc(22 * var(--u)) calc(-6 * var(--u)) var(--glow),
    inset 0 calc(-24 * var(--u)) calc(36 * var(--u)) calc(-8 * var(--u)) rgb(40 140 255 / 0.75),
    inset calc(6 * var(--u)) 0 calc(18 * var(--u)) calc(-6 * var(--u)) rgb(34 128 255 / 0.3),
    inset calc(-6 * var(--u)) 0 calc(18 * var(--u)) calc(-6 * var(--u)) rgb(34 128 255 / 0.3),
    inset 0 0 calc(70 * var(--u)) rgb(20 60 170 / 0.3);
}
.device[data-power='on'] .edge {
  animation: breathe 5s ease-in-out 1.4s infinite alternate;
}
.blackout {
  position: absolute;
  inset: 0;
  background: #02040a;
  opacity: 0;
  pointer-events: none;
}
.sweep {
  position: absolute;
  inset: 0;
  opacity: 0;
  pointer-events: none;
  box-shadow:
    inset 0 0 0 max(1.5px, calc(2.5 * var(--u))) var(--sweep),
    inset 0 0 calc(18 * var(--u)) rgb(110 235 255 / 0.95),
    inset 0 0 calc(54 * var(--u)) rgb(60 190 255 / 0.55);
  /* Two halves, each opening from the top centre: one clockwise, one the other way. */
  mask:
    conic-gradient(#000 0 calc(var(--tf-sweep) - 12deg), transparent var(--tf-sweep)),
    conic-gradient(
      transparent 0 calc(360deg - var(--tf-sweep)),
      #000 calc(372deg - var(--tf-sweep))
    );
}

/* Power on: keyframes on what each boot remounts. */
.device[data-power='on'] .sweep {
  animation:
    sweep-run 560ms cubic-bezier(0.45, 0, 0.3, 1) both,
    sweep-fade 520ms 520ms ease-out both;
}
.device[data-power='on'] .blackout {
  animation: light-up 520ms 180ms ease-out both;
}
.device[data-power='on'] .title[data-enter='boot'] {
  animation: rise 380ms 520ms cubic-bezier(0.2, 0.9, 0.3, 1.1) both;
}
.device[data-power='on'] .neighbourInner[data-enter='boot'] {
  animation: rise 380ms 600ms cubic-bezier(0.2, 0.9, 0.3, 1.1) both;
}
.device[data-power='on'] .contentInner[data-enter='boot'] {
  animation: rise 440ms 560ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.device[data-power='on'] .hint {
  animation: rise 300ms calc(160ms + var(--i) * 60ms) cubic-bezier(0.2, 0.9, 0.3, 1.1) both;
}

/* A screen change: from the side of travel. */
.device[data-power='on'] .title[data-enter='next'] {
  animation: title-from-right 320ms cubic-bezier(0.2, 0.9, 0.3, 1.15) both;
}
.device[data-power='on'] .title[data-enter='prev'] {
  animation: title-from-left 320ms cubic-bezier(0.2, 0.9, 0.3, 1.15) both;
}
.device[data-power='on'] .neighbourInner[data-enter='next'] {
  animation: from-right 300ms 40ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.device[data-power='on'] .neighbourInner[data-enter='prev'] {
  animation: from-left 300ms 40ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.device[data-power='on'] .contentInner[data-enter='next'] {
  animation: content-from-right 340ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.device[data-power='on'] .contentInner[data-enter='prev'] {
  animation: content-from-left 340ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}

/* Power off (and before the first power-on): words and content fade, then the screen darkens. */
.device[data-power='off'] :is(.title, .neighbour, .content, .hint) {
  opacity: 0;
  visibility: hidden;
  transition:
    opacity 200ms ease-in,
    visibility 0s 200ms;
}
.device[data-power='off'] .blackout {
  opacity: 1;
  transition: opacity 380ms 140ms ease-in;
}
.device[data-power='off'] .edge {
  opacity: 0.45;
  transition: opacity 420ms 120ms ease-in;
}
.device[data-cold] :is(.title, .neighbour, .content, .hint, .blackout, .edge) {
  transition: none;
}

/* The corners, over everything, clipped to the device; each is sized by its art's box in --u. */
.corners {
  position: absolute;
  inset: 0;
  z-index: 3;
  overflow: hidden;
  border-radius: inherit;
  pointer-events: none;
}
.corner {
  position: absolute;
  overflow: visible;
}
.corner[data-corner='tl'] {
  top: 0;
  left: 0;
  width: calc(160 * var(--u));
  height: calc(160 * var(--u));
}
.corner[data-corner='tr'] {
  top: 0;
  right: 0;
  width: calc(128 * var(--u));
  height: calc(260 * var(--u));
}
.corner[data-corner='br'] {
  right: 0;
  bottom: 0;
  width: calc(100 * var(--u));
  height: calc(140 * var(--u));
}
.corner[data-corner='bl'] {
  bottom: 0;
  left: 0;
  width: calc(184 * var(--u));
  height: calc(72 * var(--u));
}

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

@keyframes sweep-run {
  from {
    --tf-sweep: 0deg;
    opacity: 1;
  }
  to {
    --tf-sweep: 190deg;
    opacity: 1;
  }
}
@keyframes sweep-fade {
  from {
    --tf-sweep: 190deg;
    opacity: 1;
  }
  to {
    --tf-sweep: 190deg;
    opacity: 0;
  }
}
@keyframes light-up {
  from {
    opacity: 1;
  }
  to {
    opacity: 0;
  }
}
@keyframes rise {
  from {
    opacity: 0;
    translate: 0 calc(8 * var(--u));
  }
}
@keyframes title-from-right {
  from {
    opacity: 0;
    translate: calc(70 * var(--u)) 0;
    scale: 0.8;
  }
}
@keyframes title-from-left {
  from {
    opacity: 0;
    translate: calc(-70 * var(--u)) 0;
    scale: 0.8;
  }
}
@keyframes from-right {
  from {
    opacity: 0;
    translate: calc(36 * var(--u)) 0;
  }
}
@keyframes from-left {
  from {
    opacity: 0;
    translate: calc(-36 * var(--u)) 0;
  }
}
@keyframes content-from-right {
  from {
    opacity: 0;
    translate: calc(32 * var(--u)) 0;
  }
}
@keyframes content-from-left {
  from {
    opacity: 0;
    translate: calc(-32 * var(--u)) 0;
  }
}
@keyframes bump-next {
  30% {
    translate: calc(7 * var(--u)) 0;
  }
  65% {
    translate: calc(-2 * var(--u)) 0;
  }
}
@keyframes bump-prev {
  30% {
    translate: calc(-7 * var(--u)) 0;
  }
  65% {
    translate: calc(2 * var(--u)) 0;
  }
}
@keyframes press {
  35% {
    scale: 0.78;
    color: #e6fbff;
    box-shadow: 0 0 calc(14 * var(--u)) rgb(120 230 255 / 0.95);
  }
}
@keyframes breathe {
  from {
    opacity: 1;
  }
  to {
    opacity: 0.8;
  }
}

@media (prefers-reduced-motion: reduce) {
  .device[data-power='on'] :is(.sweep, .blackout, .title, .neighbour, .content, .hint, .edge),
  .barInner[data-bump],
  .glyph[data-flash] {
    animation: none;
  }
  .device[data-power='off'] :is(.title, .neighbour, .content, .hint, .blackout, .edge) {
    transition-duration: 0s;
    transition-delay: 0s;
  }
}
```

### tablet-frame/samples.module.css

```css
/* The sample screens (samples.tsx), drawn inside the frame's screen. Sizes are multiples of the
   frame's --u, with a floor on text so it stays readable on a phone; colours come from the
   frame's tokens (--cyan, --cyan-soft, --text-soft, --slot). */

/* Abilities: a centred column. */
.abilities {
  display: grid;
  justify-items: center;
  align-content: center;
  gap: calc(10 * var(--u));
  height: 100%;
  text-align: center;
}
.abilityName {
  margin: 0;
  font-size: max(16px, calc(25 * var(--u)));
  font-style: italic;
  font-weight: 600;
  color: var(--cyan);
  text-shadow: 0 0 calc(8 * var(--u)) var(--cyan-haze);
  animation: rise 240ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.ticks {
  width: calc(90 * var(--u));
  height: calc(4 * var(--u));
  margin-top: calc(-6 * var(--u));
  background: repeating-linear-gradient(
    90deg,
    var(--cyan-dim) 0 calc(6 * var(--u)),
    transparent 0 calc(9 * var(--u))
  );
  opacity: 0.7;
}
.slots {
  display: flex;
  gap: calc(8 * var(--u));
  margin: calc(14 * var(--u)) 0 calc(10 * var(--u));
}
.slot {
  --size: max(30px, calc(66 * var(--u)));
  display: grid;
  place-items: center;
  width: var(--size);
  height: var(--size);
  margin: 0;
  padding: 0;
  border: 1px solid rgb(120 170 220 / 0.32);
  border-radius: calc(3 * var(--u));
  background: linear-gradient(160deg, #18263c, var(--slot));
  box-shadow: inset 0 0 0 calc(2 * var(--u)) rgb(0 0 0 / 0.35);
  color: var(--hue);
  cursor: pointer;
  transition:
    transform 180ms cubic-bezier(0.3, 1.6, 0.5, 1),
    background-color 180ms,
    box-shadow 180ms;
}
.slot svg {
  width: 62%;
  height: 62%;
}
.slot[aria-selected='true'] {
  border-color: #9fe9ff;
  background: linear-gradient(160deg, #2b8fe0, #1a5fbf);
  box-shadow:
    0 0 0 1px rgb(120 220 255 / 0.5),
    0 0 calc(14 * var(--u)) rgb(60 190 255 / 0.65);
  transform: scale(1.06);
}
.slot[data-empty] {
  border-color: rgb(120 170 220 / 0.14);
  background: rgb(255 255 255 / 0.04);
  box-shadow: none;
  cursor: default;
}
.slot:focus-visible {
  outline: 2px solid #fff;
  outline-offset: 2px;
}
.abilityLine {
  margin: 0;
  font-size: max(13px, calc(19 * var(--u)));
  font-style: italic;
  font-weight: 600;
  color: var(--cyan);
  animation: rise 240ms 40ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.rule {
  position: relative;
  width: min(100%, calc(420 * var(--u)));
  height: 1px;
  margin-top: calc(-4 * var(--u));
  background: var(--cyan-dim);
}
.rule::before,
.rule::after {
  content: '';
  position: absolute;
  top: calc(-3 * var(--u));
  width: calc(6 * var(--u));
  height: calc(6 * var(--u));
  border: 1px solid var(--cyan-dim);
  border-radius: 50%;
  box-sizing: border-box;
}
.rule::before {
  left: calc(-6 * var(--u));
}
.rule::after {
  right: calc(-6 * var(--u));
}
.abilityText {
  max-width: calc(440 * var(--u));
  margin: 0;
  font-size: max(12px, calc(15 * var(--u)));
  font-style: italic;
  line-height: 1.35;
  color: var(--text-soft);
  animation: rise 240ms 80ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}

/* Map: a faint grid, the route and the markers, labels in the corners. */
.map {
  position: relative;
  height: 100%;
  min-height: calc(200 * var(--u));
  margin: calc(-18 * var(--u)) calc(-56 * var(--u));
  background:
    repeating-linear-gradient(
      90deg,
      rgb(80 170 255 / 0.1) 0 1px,
      transparent 1px calc(48 * var(--u))
    ),
    repeating-linear-gradient(
      0deg,
      rgb(80 170 255 / 0.1) 0 1px,
      transparent 1px calc(48 * var(--u))
    );
}
.route {
  position: absolute;
  inset: 8% 10%;
  width: 80%;
  height: 84%;
}
.mapLabel {
  position: absolute;
  top: 18%;
  right: 12%;
  margin: 0;
  font-size: max(12px, calc(16 * var(--u)));
  font-style: italic;
  font-weight: 600;
  color: #f4f8ff;
  text-shadow: 0 0 calc(6 * var(--u)) rgb(0 0 0 / 0.8);
}
.mapInfo {
  position: absolute;
  bottom: calc(14 * var(--u));
  left: calc(64 * var(--u));
  display: grid;
  gap: calc(2 * var(--u));
  margin: 0;
  font-size: max(10px, calc(12 * var(--u)));
  color: var(--text-soft);
}
.mapInfo div {
  display: flex;
  gap: calc(8 * var(--u));
}
.mapInfo dt {
  opacity: 0.7;
}
.mapInfo dd {
  margin: 0;
  font-variant-numeric: tabular-nums;
}

/* Album: a grid of snapshots in thin frames; the picked one is lit and bracketed. */
.album {
  display: grid;
  place-items: center;
  height: 100%;
}
.shots {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: calc(22 * var(--u)) calc(26 * var(--u));
  width: min(100%, calc(700 * var(--u)));
}
.shot {
  position: relative;
  display: block;
  aspect-ratio: 16 / 10;
  margin: 0;
  padding: calc(4 * var(--u));
  border: 1px solid rgb(170 200 230 / 0.45);
  border-radius: 0;
  background: #0b1220;
  cursor: pointer;
  transition:
    transform 180ms cubic-bezier(0.3, 1.6, 0.5, 1),
    box-shadow 180ms;
}
.art {
  display: block;
  width: 100%;
  height: 100%;
}
.number {
  position: absolute;
  bottom: calc(4 * var(--u));
  left: calc(8 * var(--u));
  font-size: max(9px, calc(12 * var(--u)));
  font-style: italic;
  font-weight: 600;
  color: #fff;
  text-shadow: 0 0 calc(3 * var(--u)) rgb(0 0 0 / 0.9);
}
.shot[aria-selected='true'] {
  border-color: #e9f6ff;
  box-shadow: 0 0 calc(14 * var(--u)) rgb(90 200 255 / 0.6);
  transform: scale(1.05);
}
.shot[aria-selected='true']::before {
  content: '';
  position: absolute;
  inset: calc(-7 * var(--u));
  --b: #9fe9ff;
  --l: calc(14 * var(--u));
  --t: max(1.5px, calc(2.5 * var(--u)));
  background:
    linear-gradient(var(--b), var(--b)) top left / var(--l) var(--t),
    linear-gradient(var(--b), var(--b)) top left / var(--t) var(--l),
    linear-gradient(var(--b), var(--b)) top right / var(--l) var(--t),
    linear-gradient(var(--b), var(--b)) top right / var(--t) var(--l),
    linear-gradient(var(--b), var(--b)) bottom left / var(--l) var(--t),
    linear-gradient(var(--b), var(--b)) bottom left / var(--t) var(--l),
    linear-gradient(var(--b), var(--b)) bottom right / var(--l) var(--t),
    linear-gradient(var(--b), var(--b)) bottom right / var(--t) var(--l);
  background-repeat: no-repeat;
  pointer-events: none;
}
.shot:focus-visible {
  outline: 2px solid #fff;
  outline-offset: 3px;
}

@keyframes rise {
  from {
    opacity: 0;
    translate: 0 calc(6 * var(--u));
  }
}

@media (prefers-reduced-motion: reduce) {
  .abilityName,
  .abilityLine,
  .abilityText {
    animation: none;
  }
  .slot,
  .shot {
    transition: none;
  }
}
```

### tablet-frame/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` and `reduced` are returned too, for
 * the frame's power-on, which waits until it is first seen.
 */
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 };
}
```