# Creature compendium

> A game tablet’s field guide: numbered frames that fill with pictures as things are photographed, category tabs, page arrows, a gliding cursor and a detail page the picture flies into.

The guide a player fills by photographing what they meet, on a navy tablet screen in a carved stone bezel. Numbered frames, 001 to 096 across five categories, show a picture once an entry is registered, a silhouette once it has been sighted, and only a lens mark and the name in the tablet’s script before that. Cyan corner marks glide from frame to frame and name the one they rest on; tabs jump between creatures, monsters, materials, equipment and treasures, and arrows turn pages. Open an entry and its picture flies out of its frame into a large one, beside a description, where it is commonly found and what it gives; step through the guide, track an entry with the sensor, and fly back. Arrow keys, the controller’s letters, a gamepad or the pointer.

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

Category: Games · Menus.

## Anatomy

1. **Header**: The stone bezel’s top bar with the tablet’s name, set by `title`, and an Album hint on the left.
2. **Category tabs**: Five icon tabs in a `tablist`, the open one lit and popped with its name riding over it; the dots above each tab count its pages, and the rail under them lights up to the open tab.
3. **Frame**: One numbered option of the page’s `listbox`: a registered entry’s picture, a sighted one’s silhouette, or an empty lens mark. Eight columns of three rows, 24 to a page.
4. **Cursor**: Four cyan corner marks over the selected frame, one element moved by a transform, so it glides from frame to frame and bumps against an end.
5. **Name**: Under the selected frame its name, or ??? until it is registered; under every other frame the name in the tablet’s script.
6. **Page arrow**: A button on each side that turns the page; it hides on the first and the last page of the guide.
7. **Sensor**: A `status` in the lower right corner that names the tracked entry and pings when tracking starts; greyed out with Sensor off when nothing is tracked.
8. **Button hints**: Track with sensor (X) and Open (A) as real buttons in the bottom bar; on the detail page Open becomes Back (B), and Track reads Stop tracking on the tracked entry.

## When to use

- A collection the player fills over the game: creatures, enemies, materials or gear that turn from unknown to sighted to registered as they are found or photographed.
- A numbered catalogue where the gaps matter: an empty frame with its number tells the player how much is left to find, and where.
- A grid browsed with a controller: one cursor moved by the d-pad or stick, shoulders for categories, triggers for pages, A to open and B to go back.
- Entries with a picture and a little lore each (where it lives, what it gives, a stat), read on a detail page the picture flies into.
- A tablet or device screen inside the game, where a stylised in-world frame is part of the fiction.

## When not to use

- An inventory the player uses or equips from. Use an item grid with counts and actions; the compendium only records what was found.
- A short list of quests or objectives. Use the quest log, whose rows carry titles, places and a story.
- An achievement or trophy list with progress bars. Use an achievement list; this grid shows pictures, not progress.
- A photo gallery of the player’s own shots. Use the photo album, which keeps every picture rather than one per entry.
- A product catalogue or image gallery in an app. Use a card grid with filters and plain focus; the tablet’s bezel, script and controller hints belong to a game.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/creature-compendium.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 { CreatureCompendium, DEFAULT_ENTRIES } from './creature-compendium';

export function Guide({ seen }: { seen: Set<string> }) {
  const entries = DEFAULT_ENTRIES.map((entry) =>
    seen.has(entry.id) ? { ...entry, status: 'registered' as const } : entry,
  );
  return (
    <CreatureCompendium
      entries={entries}
      defaultSelected="cliff-goat"
      onTrack={(entry) => console.log('tracking', entry?.name ?? 'nothing')}
    />
  );
}
```

## States

- **Registered entry selected**: The defaults with `autoplay={false}`: Creatures, page one, the cursor on 012 Cliff Goat with its name under it.
- **Sighted entry selected**: `defaultSelected="frost-fox"`: a silhouette in the frame and ??? for its name.
- **Unknown entry selected**: `defaultSelected="dune-hare"`: an empty frame with a lens mark, and ???.
- **Second page**: `defaultSelected="sky-butterfly"`: the second dot over Creatures lit, both page arrows shown, eight frames.
- **Another category**: `defaultSelected="bog-slime"`: the Monsters tab lit and named, the rail lit up to it, numbering from 033.
- **Last page**: `defaultSelected="old-chest"`: Treasures, the last page, so the next arrow is hidden.
- **Tracked**: `defaultTracked` is the selected entry: the sensor lit with its name, and the X hint reads Stop tracking.
- **Detail page**: `defaultOpen`: the big picture, the stat, description, places and effect, with arrows to step through registered entries and Back in the hints.
- **Detail of a sighted entry**: `defaultOpen` on a sighted entry: the silhouette in the big frame and ??? in every section.
- **A short guide**: Twelve entries in `entries` and a custom `title`: a part-filled page, and empty categories whose tabs have no dots.
- **Narrow**: Under 720 px the screen is 456 units wide with four columns of six rows, still 24 entries a page, and the detail page stacks.

## How it works

- Every size is a multiple of one design unit, --u, worked out on the tablet from the component’s width with a container query unit, so the screen scales as one drawing: a 1016 × 572 screen with a 45-unit header and a 44-unit footer, 93-unit frames on a 107 × 115 pitch in eight columns, 25-unit tab icons on a 45.75-unit pitch, a 223-unit picture on the detail page. Under 720 px the screen is 456 units wide with four columns, still 24 entries a page, and the detail page stacks.
- Entries are data: a name, a category, a status (registered, sighted or unknown), a picture (shape, palette, habitat) and, once registered, a description, places, a stat and an effect. They are numbered by their place in the whole guide; each category starts a new page of 24, and the dots over a tab count its pages, the current one lit.
- The cursor is one element over the grid, placed by a transform from its column and row: a step glides in 160 ms with a small overshoot; against an end it bumps 6 units and springs back (the Web Animations API on the separate translate property). Its four cyan corner marks breathe outwards. Off the last column the next page slides in from the right, column by column, 16 ms apart, and the cursor drops in; turning back slides the other way.
- Opening an entry measures its frame and the big frame and plays the big one from the frame’s place and size to its own (FLIP, 380 ms); the grid fades out, the section rules draw outwards from their titles and the text rises in a short stagger. Closing fades the text at once and flies the picture back into the frame it came from while the grid returns. Left and right step through registered entries, sliding in from the side pressed.
- A frame is CSS: a light outer line, a dark gap, an inner line, an inner cyan glow and four chamfered corners drawn by gradients; the selected frame turns white and its name replaces the script line. When an entry turns registered, its frame flashes white like a shutter and the picture develops from white to colour with a pop.
- All art is SVG drawn for this component: each picture is a setting (meadow, forest, coast, snow, desert, cave, ruins) and a flat subject with a thin dark outline; a sighted entry draws the same subject with every fill forced to one dark cyan by CSS. The tablet’s script writes each letter as a stem with hooks, bars and dots; the bezel is a stepped stone bracket with a lens boss in each corner and a carved band of blocks and studs along the bars, glowing blue.
- Uncontrolled, a demo plays while it is on screen and untouched: browse, open an entry, step twice, track it, close, change category, turn a page, register a new butterfly and open it. Any real input hands it to the reader where the demo left it.

## API reference

- **entries** (`CompendiumEntry[]`, default `a sample guide of 96`): The guide, in order: { id, name, category, status, shape, palette, habitat, description?, locations?, stat?, effect? }. Numbered in category order; an entry whose status turns registered flashes and develops. Turns the demo off.
- **title** (`string`, default `'Field Compendium'`): The name on the tablet’s header.
- **defaultSelected** (`string`): The id of the entry the cursor starts on. Turns the demo off.
- **onSelect** (`(entry: CompendiumEntry) => void`): Called when the cursor lands on an entry.
- **defaultOpen** (`boolean`, default `false`): Start on the selected entry’s detail page. Turns the demo off.
- **onOpenChange** (`(open: boolean, entry: CompendiumEntry) => void`): Called when the detail page opens or closes.
- **tracked** (`string | null`): The id of the entry the sensor tracks, controlled; pair it with onTrack.
- **defaultTracked** (`string | null`, default `null`): Uncontrolled, the entry tracked at first.
- **onTrack** (`(entry: CompendiumEntry | null) => void`): Called when the reader tracks a registered entry, or stops tracking (null).
- **keys** (`boolean`, default `true`): Listen to the keyboard while the component has focus, and to a connected gamepad.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a short browse: moves, open, step, track, a category, a page and a new entry registered. Any input stops it.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark, or the reader’s; without it, the page’s color-scheme. The tablet keeps its colours on both; its edge, shadow and the focus ring follow it.
- **className** (`string`): Added to the root.

## Accessibility

- The categories are a real tab list (arrow keys move along it, the open category is the tab in the tab order), and the page is a listbox whose options are the entries, with the cursor’s entry as the active descendant, so a screen reader reads each one as the cursor reaches it: “012, Cliff Goat”, “011, sighted, not registered”.
- Opening an entry moves focus to its detail page, a labelled group with real headings for its name and sections; closing returns focus to the grid. The hidden view is inert, so neither keyboard nor screen reader can reach it.
- The page and entry arrows, Open, Back and Track are real buttons with their controller keys in aria-keyshortcuts; the sensor is a status that names what it tracks. A polite live region reports page and category changes, tracking and a newly registered entry.
- Focus shows as an outline round the tab, the grid or the button that has it, blue on a light page and light blue on a dark one; the cursor’s corner marks show the entry inside the grid.
- All pictures, the script and the bezel are hidden from assistive technology. Under reduced motion the demo does not play and every change lands at once: no glide, bump, flight, page slide, shutter or pulse.

## Source

### creature-compendium/creature-compendium.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
} from 'react';
import {
  BezelBand,
  BezelCorner,
  EffectGlyph,
  EntryArt,
  FaceGlyph,
  Heart,
  KeyGlyph,
  Lens,
  PageArrow,
  RuleEnd,
  Script,
  SensorGlyph,
  StatMark,
  StickGlyph,
  TabIcon,
} from './art';
import {
  CATEGORIES,
  DEFAULT_ENTRIES,
  STAT_TITLE,
  pad3,
  paginate,
  type Category,
  type CompendiumEntry,
} from './entries';
import { useAutoplay } from './use-autoplay';
import styles from './creature-compendium.module.css';

export type {
  Category,
  CompendiumEntry,
  EffectIcon,
  Habitat,
  Palette,
  Shape,
  Stat,
  Status,
} from './entries';
export { DEFAULT_ENTRIES } from './entries';

/*
 * Creature compendium: the field guide of an adventure game's tablet, filled by photographing
 * what the player meets. A page is a grid of numbered frames (001…096 across five categories):
 * a registered entry shows its picture, a sighted one its silhouette, an unknown one an empty
 * frame with a lens mark and its name in the tablet's script.
 *
 * - The cursor's corner marks glide from frame to frame (arrow keys, the d-pad or stick of a
 *   gamepad, a click); the frame under them turns white and its name replaces the script. Off
 *   the last column the next page slides in; against an end the cursor bumps.
 * - Category tabs (Q and E, L and R, or a click) jump to a category's first page: the tab lights
 *   and pops, its name slides over it, the rail's lit line and stick hint follow, and dots over
 *   each tab count its pages. The page arrows (Page Up / Page Down, or a click) turn pages.
 * - A (Enter, Space, or a click on the selected frame) opens the entry: its picture flies out of
 *   the frame and grows into the big frame while the grid dims, the section rules draw out from
 *   their titles and the text rises. Left and right step through registered entries; B (Escape)
 *   flies the picture back into its frame.
 * - X (or T) tracks a registered entry with the sensor: the corner sensor pings and shows its name.
 * - When an entry turns registered (in `entries`, or in the demo), its frame flashes like a
 *   shutter and the picture develops from white.
 * - Uncontrolled and untouched on screen, a demo browses, opens, steps, tracks, changes
 *   category, turns a page and registers a new entry; any real input hands it to the reader.
 * - The tablet brings its own navy screen and stone bezel, so it reads on a light page and a dark
 *   one; only its outer edge and shadow follow the colour scheme.
 */

const TABLET = 'Field Compendium';
const WIDE = 720;
/** Moves held on a gamepad repeat after this long, then every REPEAT ms. */
const HOLD = 320;
const REPEAT = 110;

type Move = 'left' | 'right' | 'up' | 'down';
type Action =
  | Move
  | 'open'
  | 'close'
  | 'track'
  | 'prevCat'
  | 'nextCat'
  | 'prevPage'
  | 'nextPage'
  | { select: string }
  | { register: string };

const DEMO: { at: number; action: Action }[] = [
  { at: 900, action: 'left' },
  { at: 1400, action: 'left' },
  { at: 1900, action: 'up' },
  { at: 2400, action: 'left' },
  { at: 3300, action: 'open' },
  { at: 5300, action: 'right' },
  { at: 6700, action: 'right' },
  { at: 8000, action: 'track' },
  { at: 9600, action: 'close' },
  { at: 10600, action: 'nextCat' },
  { at: 11300, action: 'right' },
  { at: 11900, action: 'down' },
  { at: 12900, action: 'prevCat' },
  { at: 13700, action: 'nextPage' },
  { at: 14500, action: { select: 'dusk-butterfly' } },
  { at: 15400, action: { register: 'dusk-butterfly' } },
  { at: 17200, action: 'open' },
  { at: 19400, action: 'close' },
  { at: 20300, action: 'prevPage' },
];
const DEMO_LENGTH = 23000;
const DEMO_START = 'cliff-goat';

/** A standard-layout gamepad: right face (A), bottom (B), top (X), shoulders, triggers, d-pad. */
const PAD: [number, Action][] = [
  [1, 'open'],
  [0, 'close'],
  [3, 'track'],
  [4, 'prevCat'],
  [5, 'nextCat'],
  [6, 'prevPage'],
  [7, 'nextPage'],
  [12, 'up'],
  [13, 'down'],
  [14, 'left'],
  [15, 'right'],
];

const label = (category: Category) => CATEGORIES.find((c) => c.id === category)!;

export function CreatureCompendium({
  entries,
  title = TABLET,
  defaultSelected,
  onSelect,
  defaultOpen = false,
  onOpenChange,
  tracked,
  defaultTracked = null,
  onTrack,
  keys = true,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The guide's entries, in order (numbered 001… in category order). Turns the demo off. */
  entries?: CompendiumEntry[];
  /** The name on the tablet's header. */
  title?: string;
  /** The id of the entry the cursor starts on. Turns the demo off. */
  defaultSelected?: string;
  /** Called when the cursor lands on an entry. */
  onSelect?: (entry: CompendiumEntry) => void;
  /** Start on the selected entry's detail page. */
  defaultOpen?: boolean;
  /** Called when the detail page opens or closes. */
  onOpenChange?: (open: boolean, entry: CompendiumEntry) => void;
  /** The id of the entry the sensor tracks, controlled (null for none). */
  tracked?: string | null;
  /** Uncontrolled, the entry tracked at first. */
  defaultTracked?: string | null;
  /** Called when the reader tracks an entry or stops tracking (null). */
  onTrack?: (entry: CompendiumEntry | null) => void;
  /** Listen to the keyboard while focused, and to a connected gamepad. */
  keys?: boolean;
  /** While on screen and untouched, a short browse through the guide. */
  autoplay?: boolean;
  /** Light or dark, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, visible, reduced, take } = useAutoplay<HTMLDivElement>(8000);
  const uid = useId().replace(/[^a-zA-Z0-9-]/g, '');
  const controlled = entries !== undefined;
  const demo = autoplay && playing && !controlled && defaultSelected === undefined && !defaultOpen;

  const [ownEntries, setOwnEntries] = useState(DEFAULT_ENTRIES);
  const all = entries ?? ownEntries;
  const pages = paginate(all);
  const startId = () =>
    all.find((entry) => entry.id === (defaultSelected ?? DEMO_START))?.id ?? all[0]?.id ?? '';
  const [selectedId, setSelectedId] = useState(startId);
  const [open, setOpen] = useState(defaultOpen);
  const [ownTracked, setOwnTracked] = useState<string | null>(defaultTracked);
  const trackedId = tracked === undefined ? ownTracked : tracked;
  const [message, setMessage] = useState('');
  const [wide, setWide] = useState(true);
  const [turn, setTurn] = useState<{ n: number; dir: 1 | -1 }>({ n: 0, dir: 1 });
  const [step, setStep] = useState<{ n: number; dir: 1 | -1 }>({ n: 0, dir: 1 });
  const [hit, setHit] = useState({ open: 0, track: 0, refuse: 0, ping: 0, prev: 0, next: 0 });
  const [ran, setRan] = useState(false);

  const cols = wide ? 8 : 4;

  // Where the selected entry is.
  let pageIndex = 0;
  let slot = 0;
  pages.forEach((page, p) => {
    const i = page.entries.findIndex((entry) => entry.id === selectedId);
    if (i >= 0) {
      pageIndex = p;
      slot = i;
    }
  });
  const page = pages[pageIndex];
  const selected: CompendiumEntry | undefined = page?.entries[slot];
  const number = (page?.first ?? 0) + slot + 1;
  const numberOf = (entry: CompendiumEntry) => {
    for (const p of pages) {
      const i = p.entries.indexOf(entry);
      if (i >= 0) return p.first + i + 1;
    }
    return 0;
  };
  const tabIndex = CATEGORIES.findIndex((c) => c.id === page?.category);
  const categoryPages = pages.filter((p) => p.category === page?.category);
  const registered = pages.flatMap((p) => p.entries).filter((e) => e.status === 'registered');
  const trackedEntry = all.find((entry) => entry.id === trackedId);

  // ---- Motion helpers (the Web Animations API; nothing moves under reduced motion) ----
  const reducedRef = useRef(reduced);
  useEffect(() => {
    reducedRef.current = reduced;
  }, [reduced]);
  const animate = (
    element: Element | null | undefined,
    frames: Keyframe[],
    options: KeyframeAnimationOptions,
  ) => {
    if (!element || reducedRef.current || !element.animate) return null;
    return element.animate(frames, options);
  };
  const cursorRef = useRef<HTMLDivElement>(null);
  const bump = (direction: Move) => {
    const [x, y] = { left: [-1, 0], right: [1, 0], up: [0, -1], down: [0, 1] }[direction];
    const at = (n: number) => `calc(${x * n} * var(--u)) calc(${y * n} * var(--u))`;
    animate(
      cursorRef.current,
      [{ translate: '0 0' }, { translate: at(6) }, { translate: at(-1.5) }, { translate: '0 0' }],
      { duration: 240, easing: 'ease-out' },
    );
  };

  // ---- Actions ----
  const select = (entry: CompendiumEntry | undefined) => {
    if (!entry || entry.id === selectedId) return;
    setSelectedId(entry.id);
    onSelect?.(entry);
  };
  const rowsOf = (p: number) => Math.ceil(pages[p].entries.length / cols);
  const goToPage = (p: number, at: number, dir: 1 | -1) => {
    const target = pages[p];
    if (!target) return false;
    select(target.entries[Math.min(at, target.entries.length - 1)]);
    setTurn((now) => ({ n: now.n + 1, dir }));
    setHit((now) => (dir > 0 ? { ...now, next: now.next + 1 } : { ...now, prev: now.prev + 1 }));
    setMessage(
      `${label(target.category).label}, page ${target.index + 1} of ${
        pages.filter((q) => q.category === target.category).length
      }.`,
    );
    return true;
  };
  const move = (direction: Move) => {
    if (!page) return;
    const col = slot % cols;
    const row = Math.floor(slot / cols);
    const count = page.entries.length;
    if (direction === 'left') {
      if (col > 0) return select(page.entries[slot - 1]);
      if (pageIndex > 0) {
        const r = Math.min(row, rowsOf(pageIndex - 1) - 1);
        return void goToPage(pageIndex - 1, r * cols + cols - 1, -1);
      }
    } else if (direction === 'right') {
      if (col < cols - 1 && slot + 1 < count) return select(page.entries[slot + 1]);
      if (pageIndex < pages.length - 1) {
        const r = Math.min(row, rowsOf(pageIndex + 1) - 1);
        return void goToPage(pageIndex + 1, r * cols, 1);
      }
    } else if (direction === 'up') {
      if (row > 0) return select(page.entries[slot - cols]);
    } else if (slot + cols < count) return select(page.entries[slot + cols]);
    else if (row < rowsOf(pageIndex) - 1) return select(page.entries[count - 1]);
    bump(direction);
  };
  const turnPage = (dir: 1 | -1) => {
    if (!goToPage(pageIndex + dir, slot, dir)) bump(dir > 0 ? 'right' : 'left');
  };
  const switchCategory = (dir: 1 | -1) => {
    const next = CATEGORIES[(tabIndex + dir + CATEGORIES.length) % CATEGORIES.length].id;
    jumpTo(next, dir);
  };
  const jumpTo = (category: Category, dir: 1 | -1) => {
    const p = pages.findIndex((q) => q.category === category);
    if (p < 0 || p === pageIndex) return;
    select(pages[p].entries[0]);
    setTurn((now) => ({ n: now.n + 1, dir }));
    const c = label(category);
    setMessage(`${c.label}, ${all.filter((e) => e.category === category).length} entries.`);
  };
  const openDetail = (next: boolean) => {
    if (!selected || next === open) return;
    setOpen(next);
    setHit((now) => ({ ...now, open: now.open + 1 }));
    onOpenChange?.(next, selected);
    if (next) setStep({ n: 0, dir: 1 });
    setMessage('');
  };
  const stepDetail = (dir: 1 | -1) => {
    if (!registered.length) return;
    const here = registered.indexOf(selected!);
    let target: CompendiumEntry;
    if (here >= 0) target = registered[(here + dir + registered.length) % registered.length];
    else {
      const after = registered.filter((e) => numberOf(e) * dir > number * dir);
      target = after.length
        ? dir > 0
          ? after[0]
          : after[after.length - 1]
        : registered[dir > 0 ? 0 : registered.length - 1];
    }
    if (target === selected) return;
    select(target);
    setStep((now) => ({ n: now.n + 1, dir }));
    setHit((now) => (dir > 0 ? { ...now, next: now.next + 1 } : { ...now, prev: now.prev + 1 }));
  };
  const track = () => {
    if (!selected) return;
    if (selected.status !== 'registered') {
      setHit((now) => ({ ...now, refuse: now.refuse + 1 }));
      setMessage('Only registered entries can be tracked.');
      return;
    }
    const next = trackedId === selected.id ? null : selected.id;
    if (tracked === undefined) setOwnTracked(next);
    onTrack?.(next ? selected : null);
    setHit((now) => ({ ...now, track: now.track + 1, ping: next ? now.ping + 1 : now.ping }));
    setMessage(next ? `Tracking ${selected.name} with the sensor.` : 'Sensor off.');
  };
  const register = (id: string) => {
    if (controlled) return;
    setOwnEntries((now) =>
      now.map((entry) => (entry.id === id ? { ...entry, status: 'registered' } : entry)),
    );
  };

  const act = useEffectEvent((action: Action) => {
    if (typeof action === 'object') {
      if ('select' in action) select(all.find((entry) => entry.id === action.select));
      else register(action.register);
      return;
    }
    if (open) {
      if (action === 'left' || action === 'prevPage') stepDetail(-1);
      else if (action === 'right' || action === 'nextPage') stepDetail(1);
      else if (action === 'close') openDetail(false);
      else if (action === 'track') track();
      return;
    }
    if (action === 'left' || action === 'right' || action === 'up' || action === 'down')
      move(action);
    else if (action === 'open') openDetail(true);
    else if (action === 'track') track();
    else if (action === 'prevCat') switchCategory(-1);
    else if (action === 'nextCat') switchCategory(1);
    else if (action === 'prevPage') turnPage(-1);
    else if (action === 'nextPage') turnPage(1);
  });

  // ---- Registering: an entry that turns registered flashes and develops ----
  const lastStatus = useRef(new Map(all.map((entry) => [entry.id, entry.status])));
  const [developed, setDeveloped] = useState<{ id: string; n: number } | null>(null);
  const noticeRegistered = useEffectEvent((entry: CompendiumEntry) => {
    setDeveloped((now) => ({ id: entry.id, n: (now?.n ?? 0) + 1 }));
    setMessage(`Registered: ${entry.name}.`);
  });
  useEffect(() => {
    const before = lastStatus.current;
    for (const entry of all) {
      const was = before.get(entry.id);
      if (was && was !== 'registered' && entry.status === 'registered') noticeRegistered(entry);
    }
    lastStatus.current = new Map(all.map((entry) => [entry.id, entry.status]));
  }, [all]);

  // ---- Layout: eight columns on a wide tablet, four on a narrow one ----
  useLayoutEffect(() => {
    const element = root.current;
    if (!element) return;
    const observer = new ResizeObserver(([entry]) => setWide(entry.contentRect.width >= WIDE));
    observer.observe(element);
    return () => observer.disconnect();
  }, [root]);

  // ---- The demo ----
  const timers = useRef(new Set<number>());
  const later = (ms: number, run: () => void) => {
    const timer = window.setTimeout(() => {
      timers.current.delete(timer);
      run();
    }, ms);
    timers.current.add(timer);
  };
  const reset = useEffectEvent(() => {
    setOwnEntries(DEFAULT_ENTRIES);
    lastStatus.current = new Map(DEFAULT_ENTRIES.map((entry) => [entry.id, entry.status]));
    setSelectedId(DEMO_START);
    setOpen(false);
    setOwnTracked(defaultTracked);
  });
  useEffect(() => {
    if (!demo) return;
    const run = () => {
      reset();
      for (const { at, action } of DEMO) later(at, () => act(action));
    };
    setRan(true);
    run();
    const loop = window.setInterval(run, DEMO_LENGTH);
    const pending = timers.current;
    return () => {
      window.clearInterval(loop);
      pending.forEach((timer) => window.clearTimeout(timer));
      pending.clear();
    };
  }, [demo]);
  const handOver = useCallback(() => {
    take();
    if (ran) setRan(false);
  }, [take, ran]);

  // ---- Keys ----
  const onKeyDown = (event: React.KeyboardEvent) => {
    if (!keys || event.metaKey || event.ctrlKey || event.altKey) return;
    const target = event.target as HTMLElement;
    const inTabs = Boolean(target.closest('[role="tablist"]'));
    const inButton = target.tagName === 'BUTTON' && !inTabs;
    const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
    let action: Action | null = null;
    if (key === 'q' || key === 'l') action = 'prevCat';
    else if (key === 'e' || key === 'r') action = 'nextCat';
    else if (key === 'PageUp') action = 'prevPage';
    else if (key === 'PageDown') action = 'nextPage';
    else if (key === 'x' || key === 't') action = 'track';
    else if (key === 'Escape' || key === 'b' || key === 'Backspace') action = open ? 'close' : null;
    else if (inTabs && key === 'ArrowLeft') action = 'prevCat';
    else if (inTabs && key === 'ArrowRight') action = 'nextCat';
    else if (inButton) return;
    else if (key === 'ArrowLeft') action = 'left';
    else if (key === 'ArrowRight') action = 'right';
    else if (key === 'ArrowUp') action = open ? null : 'up';
    else if (key === 'ArrowDown') action = open ? null : 'down';
    else if (key === 'a' || key === 'Enter' || key === ' ') action = open ? null : 'open';
    else if ((key === 'Home' || key === 'End') && !open && page) {
      event.preventDefault();
      handOver();
      select(page.entries[key === 'Home' ? 0 : page.entries.length - 1]);
      return;
    }
    if (!action) return;
    event.preventDefault();
    handOver();
    act(action);
    if (inTabs && (action === 'prevCat' || action === 'nextCat')) focusTab.current = true;
  };

  const focusTab = useRef(false);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
  useEffect(() => {
    if (!focusTab.current) return;
    focusTab.current = false;
    tabRefs.current[tabIndex]?.focus();
  }, [tabIndex]);

  // ---- A gamepad: polled once one is connected (browsers report one only after a gesture) ----
  const [pad, setPad] = useState(false);
  useEffect(() => {
    if (!keys || !('getGamepads' in navigator)) return;
    const connected = () => setPad(navigator.getGamepads().some(Boolean));
    connected();
    window.addEventListener('gamepadconnected', connected);
    window.addEventListener('gamepaddisconnected', connected);
    return () => {
      window.removeEventListener('gamepadconnected', connected);
      window.removeEventListener('gamepaddisconnected', connected);
    };
  }, [keys]);
  const downSince = useRef(new Map<number, number>());
  const poll = useEffectEvent((now: number) => {
    const down = new Map<number, Action>();
    navigator.getGamepads().forEach((gamepad) => {
      if (!gamepad) return;
      PAD.forEach(([button, action], i) => {
        if (gamepad.buttons[button]?.pressed) down.set(i, action);
      });
      const [x = 0, y = 0] = gamepad.axes;
      if (y < -0.5) down.set(100, 'up');
      if (y > 0.5) down.set(101, 'down');
      if (x < -0.5) down.set(102, 'left');
      if (x > 0.5) down.set(103, 'right');
    });
    for (const key of [...downSince.current.keys()])
      if (!down.has(key)) downSince.current.delete(key);
    for (const [key, action] of down) {
      const since = downSince.current.get(key);
      const moving =
        action === 'left' || action === 'right' || action === 'up' || action === 'down';
      if (since === undefined) {
        downSince.current.set(key, now);
        handOver();
        act(action);
      } else if (moving && now - since > HOLD) {
        downSince.current.set(key, now - HOLD + REPEAT);
        act(action);
      }
    }
  });
  useEffect(() => {
    if (!pad || !visible) return;
    let frame = requestAnimationFrame(function tick(now) {
      poll(now);
      frame = requestAnimationFrame(tick);
    });
    return () => cancelAnimationFrame(frame);
  }, [pad, visible]);

  // ---- Open and close: the picture flies between its frame in the grid and the big frame ----
  const cells = useRef(new Map<string, HTMLElement>());
  const bigRef = useRef<HTMLDivElement>(null);
  const gridRef = useRef<HTMLDivElement>(null);
  const detailRef = useRef<HTMLDivElement>(null);
  const flight = useRef<Animation | null>(null);
  const wasOpen = useRef(open);
  useLayoutEffect(() => {
    if (wasOpen.current === open) return;
    wasOpen.current = open;
    const big = bigRef.current;
    const cell = selected ? cells.current.get(selected.id) : undefined;
    flight.current?.cancel();
    flight.current = null;
    if (open) detailRef.current?.focus({ preventScroll: true });
    else gridRef.current?.focus({ preventScroll: true });
    if (!big || !cell) return;
    const a = cell.getBoundingClientRect();
    const b = big.getBoundingClientRect();
    if (!a.width || !b.width) return;
    const toCell = `translate(${a.left - b.left}px, ${a.top - b.top}px) scale(${a.width / b.width}, ${a.height / b.height})`;
    flight.current = open
      ? animate(big, [{ transform: toCell }, { transform: 'none' }], {
          duration: 380,
          easing: 'cubic-bezier(0.2, 0.85, 0.25, 1)',
        })
      : animate(big, [{ transform: 'none' }, { transform: toCell }], {
          duration: 300,
          easing: 'cubic-bezier(0.4, 0, 0.3, 1)',
          fill: 'forwards',
        });
  }, [open]);

  const lineX = `calc(${Math.max(0, tabIndex)} * var(--tab-pitch))`;
  const shownTitle = selected?.status === 'registered' ? selected.name : '???';
  const category = page ? label(page.category) : CATEGORIES[0];

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      data-reduced={reduced || undefined}
      onPointerDown={handOver}
      onKeyDown={onKeyDown}
    >
      <section
        className={styles.tablet}
        data-part="tablet"
        data-open={open || undefined}
        aria-label={title}
      >
        <header className={styles.top} data-part="header">
          <BezelBand id={`${uid}-band-top`} className={styles.band} />
          <p className={styles.album} aria-hidden="true">
            Album <KeyGlyph letter="L" className={styles.keySvg} />
          </p>
          <h2 className={styles.title}>{title}</h2>
        </header>

        <div className={styles.panel} data-part="panel">
          {/* ---- The grid view ---- */}
          <div className={styles.gridView} data-part="grid-view" inert={open}>
            <div
              className={styles.head}
              data-part="tabs"
              style={{ '--tab': Math.max(0, tabIndex) } as CSSProperties}
            >
              <p className={styles.heading} aria-hidden="true">
                <span key={category.id} className={styles.headingWord}>
                  {category.label}
                </span>
              </p>
              <div className={styles.tabs} role="tablist" aria-label="Categories">
                {CATEGORIES.map((c, i) => {
                  const active = i === tabIndex;
                  const count = pages.filter((p) => p.category === c.id).length;
                  return (
                    <button
                      key={c.id}
                      ref={(element) => {
                        tabRefs.current[i] = element;
                      }}
                      type="button"
                      role="tab"
                      id={`${uid}-tab-${c.id}`}
                      aria-selected={active}
                      aria-controls={`${uid}-grid`}
                      aria-label={c.label}
                      tabIndex={active ? 0 : -1}
                      className={styles.tab}
                      data-part="tab"
                      data-active={active || undefined}
                      onClick={() => {
                        handOver();
                        jumpTo(c.id, i > tabIndex ? 1 : -1);
                      }}
                    >
                      <span className={styles.dots} data-part="dots" aria-hidden="true">
                        {Array.from({ length: count }, (_, d) => (
                          <span
                            key={d}
                            className={styles.dot}
                            data-on={(active && d === page.index) || undefined}
                          />
                        ))}
                      </span>
                      <span key={active ? `on-${turn.n}` : 'off'} className={styles.tabIcon}>
                        <TabIcon category={c.id} className={styles.tabSvg} />
                      </span>
                    </button>
                  );
                })}
              </div>
              <div
                className={styles.rail}
                data-part="rail"
                aria-hidden="true"
                style={{ '--x': lineX } as CSSProperties}
              >
                <span className={styles.railLit} />
                <span className={styles.railStick}>
                  <StickGlyph className={styles.stickSvg} />
                </span>
              </div>
            </div>

            <div
              ref={gridRef}
              id={`${uid}-grid`}
              className={styles.grid}
              data-part="grid"
              data-turn={turn.n ? (turn.dir > 0 ? 'next' : 'prev') : undefined}
              role="listbox"
              tabIndex={open ? -1 : 0}
              aria-label={`${category.label}, page ${(page?.index ?? 0) + 1} of ${categoryPages.length}`}
              aria-activedescendant={selected ? `${uid}-entry-${selected.id}` : undefined}
              aria-keyshortcuts={keys ? 'A X Q E PageUp PageDown' : undefined}
            >
              {page?.entries.map((entry, i) => {
                const isSelected = i === slot;
                const n = page.first + i + 1;
                const flash = developed?.id === entry.id ? developed.n : 0;
                return (
                  <div
                    key={entry.id}
                    id={`${uid}-entry-${entry.id}`}
                    role="option"
                    aria-selected={isSelected}
                    aria-label={`${pad3(n)}, ${
                      entry.status === 'registered'
                        ? entry.name
                        : entry.status === 'sighted'
                          ? 'sighted, not registered'
                          : 'not registered'
                    }${trackedId === entry.id ? ', tracked' : ''}`}
                    className={styles.cell}
                    data-part="cell"
                    data-status={entry.status}
                    data-selected={isSelected || undefined}
                    style={{ '--col': i % cols, '--row': Math.floor(i / cols) } as CSSProperties}
                    onClick={() => {
                      if (isSelected) openDetail(true);
                      else select(entry);
                    }}
                  >
                    <div
                      className={styles.frame}
                      data-part="frame"
                      ref={(element) => {
                        if (element) cells.current.set(entry.id, element);
                        else cells.current.delete(entry.id);
                      }}
                    >
                      {entry.status === 'unknown' ? (
                        <Lens className={styles.lens} />
                      ) : (
                        <span
                          key={flash}
                          className={styles.picture}
                          data-flash={flash || undefined}
                        >
                          <EntryArt
                            id={`${uid}-a-${entry.id}`}
                            shape={entry.shape}
                            palette={entry.palette}
                            habitat={entry.habitat}
                            silhouette={entry.status === 'sighted'}
                            className={styles.art}
                          />
                        </span>
                      )}
                      {flash > 0 && (
                        <span key={`r${flash}`} className={styles.shutter} aria-hidden="true" />
                      )}
                      <span className={styles.number} data-part="number" aria-hidden="true">
                        {pad3(n)}
                      </span>
                    </div>
                    <span className={styles.caption} data-part="caption" aria-hidden="true">
                      {isSelected ? (
                        <span className={styles.name}>
                          {entry.status === 'registered' ? entry.name : '???'}
                        </span>
                      ) : (
                        <Script text={entry.name} className={styles.script} />
                      )}
                    </span>
                  </div>
                );
              })}
              {selected && (
                <div
                  key={`${pageIndex}-${cols}`}
                  ref={cursorRef}
                  className={styles.cursor}
                  data-part="cursor"
                  aria-hidden="true"
                  style={
                    { '--col': slot % cols, '--row': Math.floor(slot / cols) } as CSSProperties
                  }
                >
                  <span data-corner="tl" />
                  <span data-corner="tr" />
                  <span data-corner="bl" />
                  <span data-corner="br" />
                </div>
              )}
            </div>

            <button
              type="button"
              className={styles.arrow}
              data-side="prev"
              data-part="page-arrow"
              hidden={pageIndex === 0}
              aria-label="Previous page"
              onClick={() => {
                handOver();
                turnPage(-1);
              }}
            >
              <span key={hit.prev} className={styles.arrowGlyph} data-hit={hit.prev || undefined}>
                <PageArrow className={styles.arrowSvg} />
              </span>
            </button>
            <button
              type="button"
              className={styles.arrow}
              data-side="next"
              data-part="page-arrow"
              hidden={pageIndex >= pages.length - 1}
              aria-label="Next page"
              onClick={() => {
                handOver();
                turnPage(1);
              }}
            >
              <span key={hit.next} className={styles.arrowGlyph} data-hit={hit.next || undefined}>
                <PageArrow className={styles.arrowSvg} />
              </span>
            </button>
          </div>

          {/* ---- The detail view ---- */}
          <div
            ref={detailRef}
            className={styles.detailView}
            data-part="detail"
            role="group"
            aria-roledescription="entry"
            aria-label={selected ? `${pad3(number)} ${shownTitle}` : undefined}
            tabIndex={open ? -1 : undefined}
            inert={!open}
          >
            {selected && (
              <Detail
                key={hit.open}
                entry={selected}
                number={number}
                uid={uid}
                bigRef={bigRef}
                step={step}
                effectTitle={category.effectTitle}
              />
            )}
            {registered.length > 1 && (
              <>
                <button
                  type="button"
                  className={styles.arrow}
                  data-side="prev"
                  aria-label="Previous entry"
                  onClick={() => {
                    handOver();
                    stepDetail(-1);
                  }}
                >
                  <span
                    key={hit.prev}
                    className={styles.arrowGlyph}
                    data-hit={hit.prev || undefined}
                  >
                    <PageArrow className={styles.arrowSvg} />
                  </span>
                </button>
                <button
                  type="button"
                  className={styles.arrow}
                  data-side="next"
                  aria-label="Next entry"
                  onClick={() => {
                    handOver();
                    stepDetail(1);
                  }}
                >
                  <span
                    key={hit.next}
                    className={styles.arrowGlyph}
                    data-hit={hit.next || undefined}
                  >
                    <PageArrow className={styles.arrowSvg} />
                  </span>
                </button>
              </>
            )}
          </div>

          {/* ---- The sensor ---- */}
          <div
            className={styles.sensor}
            data-part="sensor"
            data-on={trackedEntry ? true : undefined}
            aria-label={trackedEntry ? `Sensor tracking ${trackedEntry.name}` : 'Sensor off'}
            role="status"
          >
            <span className={styles.sensorText}>
              <span key={trackedEntry?.id ?? 'off'} className={styles.sensorName}>
                {trackedEntry ? trackedEntry.name : 'Sensor off'}
              </span>
              <Script
                text={trackedEntry ? trackedEntry.name : 'sensor off'}
                className={styles.sensorScript}
              />
            </span>
            <span className={styles.sensorIcon}>
              <SensorGlyph className={styles.sensorSvg} />
              {hit.ping > 0 && trackedEntry && (
                <span key={hit.ping} className={styles.ping} aria-hidden="true">
                  <span />
                  <span />
                </span>
              )}
            </span>
          </div>
        </div>

        <footer className={styles.bottom} data-part="hints">
          <div className={styles.hints}>
            {open && (
              <button
                type="button"
                className={styles.hint}
                aria-keyshortcuts={keys ? 'B Escape' : undefined}
                onClick={() => {
                  handOver();
                  openDetail(false);
                }}
              >
                <span>Back</span>
                <span key={hit.open} className={styles.hintGlyph} data-hit={hit.open || undefined}>
                  <FaceGlyph letter="B" className={styles.faceSvg} />
                </span>
              </button>
            )}
            <button
              type="button"
              className={styles.hint}
              aria-keyshortcuts={keys ? 'X' : undefined}
              data-refuse={hit.refuse || undefined}
              key={`track-${hit.refuse}`}
              onClick={() => {
                handOver();
                track();
              }}
            >
              <span>
                {selected && trackedId === selected.id ? 'Stop tracking' : 'Track with sensor'}
              </span>
              <span key={hit.track} className={styles.hintGlyph} data-hit={hit.track || undefined}>
                <FaceGlyph letter="X" className={styles.faceSvg} />
              </span>
            </button>
            {!open && (
              <button
                type="button"
                className={styles.hint}
                aria-keyshortcuts={keys ? 'A Enter' : undefined}
                onClick={() => {
                  handOver();
                  openDetail(true);
                }}
              >
                <span>Open</span>
                <span key={hit.open} className={styles.hintGlyph} data-hit={hit.open || undefined}>
                  <FaceGlyph letter="A" className={styles.faceSvg} />
                </span>
              </button>
            )}
          </div>
          <BezelBand id={`${uid}-band-bottom`} className={styles.band} />
        </footer>

        <BezelCorner id={`${uid}-c1`} className={styles.corner} data-at="tl" />
        <BezelCorner id={`${uid}-c2`} className={styles.corner} data-at="tr" />
        <BezelCorner id={`${uid}-c3`} className={styles.corner} data-at="bl" />
        <BezelCorner id={`${uid}-c4`} className={styles.corner} data-at="br" />
      </section>
      <p className={styles.live} aria-live="polite">
        {message}
      </p>
    </div>
  );
}

/** The detail page of one entry: the big picture, its number and name, and its sections. */
function Detail({
  entry,
  number,
  uid,
  bigRef,
  step,
  effectTitle,
}: {
  entry: CompendiumEntry;
  number: number;
  uid: string;
  bigRef: React.RefObject<HTMLDivElement | null>;
  step: { n: number; dir: 1 | -1 };
  effectTitle: string;
}) {
  const known = entry.status === 'registered';
  const dir = step.n ? (step.dir > 0 ? 'next' : 'prev') : undefined;
  const stat = known ? entry.stat : undefined;
  return (
    <div className={styles.detail}>
      <div className={styles.dLeft}>
        <h3 key={`t-${entry.id}`} className={styles.dTitle} data-dir={dir}>
          {pad3(number)} {known ? entry.name : '???'}
        </h3>
        <div ref={bigRef} className={styles.big} data-part="picture">
          <span key={entry.id} className={styles.bigInner} data-dir={dir}>
            {entry.status === 'unknown' ? (
              <Lens className={styles.bigLens} />
            ) : (
              <EntryArt
                id={`${uid}-big-${entry.id}`}
                shape={entry.shape}
                palette={entry.palette}
                habitat={entry.habitat}
                silhouette={entry.status === 'sighted'}
                className={styles.bigArt}
              />
            )}
          </span>
        </div>
        <section className={styles.section} data-slot="stat" key={`s-${entry.id}`} data-dir={dir}>
          <Rule>{stat ? STAT_TITLE[stat.kind] : 'Notes'}</Rule>
          <p className={styles.statLine}>
            {!stat ? (
              <span className={styles.dim}>—</span>
            ) : stat.kind === 'hearts' ? (
              stat.value ? (
                <span className={styles.hearts} aria-label={`${stat.value} hearts`}>
                  {Array.from({ length: stat.value }, (_, i) => (
                    <Heart key={i} className={styles.heartSvg} />
                  ))}
                </span>
              ) : (
                <span className={styles.dim}>None</span>
              )
            ) : stat.kind === 'threat' ? (
              <span className={styles.marks} aria-label={`Threat ${stat.value} of 5`}>
                {Array.from({ length: 5 }, (_, i) => (
                  <StatMark
                    key={i}
                    kind="threat"
                    className={styles.markSvg}
                    data-on={i < stat.value || undefined}
                  />
                ))}
              </span>
            ) : (
              <span className={styles.value}>
                {stat.kind === 'value' && <StatMark kind="value" className={styles.valueSvg} />}
                {stat.value}
              </span>
            )}
          </p>
        </section>
      </div>
      <div className={styles.dRight} key={`r-${entry.id}`} data-dir={dir}>
        <section className={styles.section} data-slot="description">
          <Rule>Description</Rule>
          <p className={styles.text}>
            {known
              ? entry.description
              : entry.status === 'sighted'
                ? 'Seen, but not yet photographed. Take its picture to register it.'
                : 'No data. Photograph it to register this entry.'}
          </p>
        </section>
        <section className={styles.section} data-slot="locations">
          <Rule>Common Locations</Rule>
          <ul className={styles.places}>
            {(known ? (entry.locations ?? []) : ['???']).map((place) => (
              <li key={place}>{place}</li>
            ))}
          </ul>
        </section>
        <section className={styles.section} data-slot="effect">
          <Rule>{effectTitle}</Rule>
          <p className={styles.effect}>
            {known && entry.effect ? (
              <>
                {entry.effect.icon && (
                  <EffectGlyph icon={entry.effect.icon} className={styles.effectSvg} />
                )}
                {entry.effect.label}
              </>
            ) : (
              <span className={styles.dim}>{known ? 'None' : '???'}</span>
            )}
          </p>
        </section>
      </div>
    </div>
  );
}

function Rule({ children }: { children: React.ReactNode }) {
  return (
    <h4 className={styles.rule}>
      <RuleEnd className={styles.ruleEnd} />
      <span className={styles.ruleLine} data-side="start" />
      <span className={styles.ruleText}>{children}</span>
      <span className={styles.ruleLine} data-side="end" />
      <RuleEnd className={styles.ruleEnd} data-flip />
    </h4>
  );
}
```

### creature-compendium/creature-compendium.module.css

```css
/* Creature compendium (creature-compendium.tsx): a tablet screen showing a numbered field guide,
   centred on a plain stage.

   Every size is a multiple of --u, one design unit (1 px at full size), set on the tablet from the
   component's width (a container query unit), so the screen scales as one drawing and keeps its
   proportions: a 1016 × 572 screen with a 45-unit header and a 44-unit footer, 93-unit frames on a
   107 × 115 pitch in eight columns, 25-unit tab icons on a 45.75 pitch, a 223-unit picture on the
   detail page. Under 720 px the screen is 456 units wide, four columns and taller, and the detail
   page stacks.

   Colours are the tokens below. The screen keeps its colours in both schemes, as a game's tablet
   does: navy glass, cyan type, white selection, a stone bezel with a blue glow. Only the screen's
   outer edge and shadow and the focus ring follow the scheme, through light-dark().

   Motion: the cursor glides by a transform transition; bumps, the picture's flight between its
   frame and the big frame, and nothing else, are played by the script (Web Animations API); page
   turns, tab pops, rules drawing, text rising, the shutter flash and the sensor's ping are CSS
   animations, replayed by remounting their element. */

.root {
  /* The tablet's own colours. */
  --bar: #0d0d0f;
  --navy: #171a38;
  --navy-lit: #1e2249;
  --glow: rgb(24 120 240 / 0.85);
  --cyan: #3cbcf2;
  --cyan-hi: #2ef3ff;
  --cyan-dim: #1b6b98;
  --script: #3580ae;
  --frame-line: #8b96a7;
  --frame-inner: #4a5668;
  --frame-sel: #f2f8ff;
  --cell: #050b14;
  --cell-glow: rgb(36 165 240 / 0.58);
  --lens: #0c3247;
  --silhouette: #154763;
  --rail: #24407a;
  --glyph: #3cc4f8;
  --glyph-ink: #0f2248;
  --glyph-shade: #1b6aa2;
  --sensor-ground: #a6e6ff;
  --sensor-ink: #12406e;
  /* The page around it. */
  --edge: rgb(255 255 255 / 0);
  --shade: rgb(20 24 50 / 0.3);
  --focus: #2f6fdf;
  position: relative;
  container-type: inline-size;
  isolation: isolate;
  display: grid;
  place-items: center;
  box-sizing: border-box;
  width: 100%;
  min-height: 360px;
  padding: 28px 16px;
  font-family:
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --edge: light-dark(rgb(255 255 255 / 0), rgb(255 255 255 / 0.12));
    --shade: light-dark(rgb(20 24 50 / 0.3), rgb(0 0 0 / 0.55));
    --focus: light-dark(#2f6fdf, #8db4ff);
  }
}
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

/* ---- The tablet: a fixed 16:9 screen when wide, a taller narrow one under 720 px ---- */

.tablet {
  --u: min(1px, calc(100cqi / 456));
  --cols: 4;
  --cell-size: calc(93 * var(--u));
  --pitch-x: calc(107 * var(--u));
  --pitch-y: calc(115 * var(--u));
  --tab-pitch: calc(45.75 * var(--u));
  position: relative;
  display: grid;
  grid-template-rows: calc(45 * var(--u)) auto calc(44 * var(--u));
  box-sizing: border-box;
  width: calc(456 * var(--u));
  overflow: hidden;
  border-radius: calc(12 * var(--u));
  background: var(--bar);
  box-shadow:
    0 0 0 1px var(--edge),
    0 calc(16 * var(--u)) calc(40 * var(--u)) var(--shade);
  color: var(--cyan);
  font-size: calc(14 * var(--u));
  line-height: 1.2;
  text-shadow: 0 0 calc(5 * var(--u)) rgb(40 170 255 / 0.35);
  user-select: none;
  -webkit-user-select: none;
}
@container (min-width: 720px) {
  .tablet {
    --u: min(1px, calc(100cqi / 1016));
    --cols: 8;
    grid-template-rows: calc(45 * var(--u)) calc(483 * var(--u)) calc(44 * var(--u));
    width: calc(1016 * var(--u));
  }
}

/* ---- Header and footer bars, with the carved band ---- */

.top,
.bottom {
  position: relative;
  z-index: 3;
  background:
    radial-gradient(circle, rgb(255 255 255 / 0.05) 0.5px, transparent 0.9px) 0 0 /
      calc(5 * var(--u)) calc(5 * var(--u)),
    var(--bar);
}
.band {
  position: absolute;
  left: calc(120 * var(--u));
  right: calc(120 * var(--u));
  width: calc(100% - 240 * var(--u));
  height: calc(14 * var(--u));
}
.top .band {
  top: calc(1 * var(--u));
}
.bottom .band {
  bottom: calc(1 * var(--u));
}
.top {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  padding-top: calc(20 * var(--u));
  box-sizing: border-box;
}
.album {
  grid-column: 1;
  justify-self: end;
  display: flex;
  align-items: center;
  gap: calc(6 * var(--u));
  margin: 0 calc(38 * var(--u)) 0 0;
  font-size: calc(13.5 * var(--u));
  font-style: italic;
  font-weight: 700;
  color: var(--cyan);
}
.keySvg {
  display: block;
  width: calc(18 * var(--u));
  height: calc(16 * var(--u));
}
.title {
  grid-column: 2;
  margin: 0;
  font-size: calc(19 * var(--u));
  font-weight: 500;
  line-height: calc(22 * var(--u));
  white-space: nowrap;
  color: #34b4ec;
}
@container (max-width: 719.98px) {
  .album {
    margin-right: calc(14 * var(--u));
    font-size: max(10px, calc(13.5 * var(--u)));
  }
  .title {
    font-size: max(12px, calc(19 * var(--u)));
  }
}

.bottom {
  display: flex;
  justify-content: flex-end;
}
.hints {
  display: flex;
  gap: calc(30 * var(--u));
  margin: calc(4 * var(--u)) calc(99 * var(--u)) 0 0;
  height: calc(20 * var(--u));
}
@container (max-width: 719.98px) {
  .hints {
    margin-right: calc(30 * var(--u));
    gap: calc(18 * var(--u));
  }
}
.hint {
  display: inline-flex;
  align-items: center;
  gap: calc(5 * var(--u));
  margin: 0;
  padding: 0 calc(1 * var(--u)) 0 calc(3 * var(--u));
  border: 0;
  border-radius: calc(10 * var(--u));
  background: none;
  color: var(--cyan);
  font: inherit;
  font-size: max(10px, calc(14.5 * var(--u)));
  font-weight: 600;
  white-space: nowrap;
  cursor: pointer;
  touch-action: manipulation;
}
.hint:hover {
  color: #7fd8ff;
}
.hint:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.hint[data-refuse] {
  animation: refuse 280ms linear;
}
@keyframes refuse {
  20% {
    translate: calc(-3 * var(--u)) 0;
  }
  45% {
    translate: calc(3 * var(--u)) 0;
  }
  70% {
    translate: calc(-2 * var(--u)) 0;
  }
}
.hintGlyph {
  display: block;
}
.hintGlyph[data-hit] {
  animation: press 260ms cubic-bezier(0.3, 1.5, 0.5, 1);
}
@keyframes press {
  from {
    scale: 0.75;
  }
}
.faceSvg {
  display: block;
  width: calc(18 * var(--u));
  height: calc(18 * var(--u));
}

/* ---- The bezel corners: stone brackets with a blue glow behind ---- */

.corner {
  position: absolute;
  z-index: 4;
  width: calc(112 * var(--u));
  height: calc(105 * var(--u));
  pointer-events: none;
  filter: drop-shadow(0 0 calc(3 * var(--u)) rgb(40 150 255 / 0.9))
    drop-shadow(0 0 calc(12 * var(--u)) rgb(20 110 240 / 0.7));
}
@container (min-width: 720px) {
  .corner {
    width: calc(160 * var(--u));
    height: calc(150 * var(--u));
  }
}
.corner[data-at='tl'] {
  left: 0;
  top: 0;
}
.corner[data-at='tr'] {
  right: 0;
  top: 0;
  scale: -1 1;
}
.corner[data-at='bl'] {
  left: 0;
  bottom: 0;
  scale: 1 -1;
}
.corner[data-at='br'] {
  right: 0;
  bottom: 0;
  scale: -1 -1;
}

/* ---- The panel: navy glass, a fine dot texture, a glow along its edges ---- */

.panel {
  position: relative;
  display: grid;
  background:
    radial-gradient(
        circle,
        rgb(140 170 255 / 0.07) calc(0.9 * var(--u)),
        transparent calc(1.2 * var(--u))
      )
      0 0 / calc(9 * var(--u)) calc(9 * var(--u)),
    radial-gradient(ellipse 70% 60% at 50% 48%, var(--navy-lit), transparent),
    var(--navy);
  box-shadow:
    inset 0 calc(10 * var(--u)) calc(16 * var(--u)) calc(-6 * var(--u)) var(--glow),
    inset 0 calc(-10 * var(--u)) calc(16 * var(--u)) calc(-6 * var(--u)) var(--glow),
    inset calc(8 * var(--u)) 0 calc(18 * var(--u)) calc(-8 * var(--u)) rgb(24 120 240 / 0.5),
    inset calc(-8 * var(--u)) 0 calc(18 * var(--u)) calc(-8 * var(--u)) rgb(24 120 240 / 0.5);
}
.gridView,
.detailView {
  grid-area: 1 / 1;
  position: relative;
  min-width: 0;
}
@container (min-width: 720px) {
  .gridView,
  .detailView {
    position: absolute;
    inset: 0;
  }
}
.gridView {
  padding-bottom: calc(64 * var(--u));
  transition: opacity 200ms ease;
}
.tablet[data-open] .gridView {
  opacity: 0;
  visibility: hidden;
  transition:
    opacity 160ms ease,
    visibility 0s 160ms;
}
.detailView {
  outline: none;
  visibility: hidden;
  transition: visibility 0s 300ms;
}
.tablet[data-open] .detailView {
  visibility: visible;
  transition: none;
}

/* ---- Tabs ---- */

.head {
  position: relative;
  width: calc(5 * var(--tab-pitch));
  height: calc(87 * var(--u));
  margin: 0 auto;
}
.heading {
  position: absolute;
  left: calc(var(--tab) * var(--tab-pitch) + var(--tab-pitch) / 2);
  top: calc(11 * var(--u));
  margin: 0;
  font-size: max(9px, calc(13 * var(--u)));
  font-style: italic;
  font-weight: 500;
  line-height: calc(18 * var(--u));
  white-space: nowrap;
  translate: -50% 0;
  transition: left 240ms cubic-bezier(0.3, 1.2, 0.5, 1);
}
.headingWord {
  display: block;
  animation: word-in 220ms cubic-bezier(0.2, 0.8, 0.3, 1) both;
}
@keyframes word-in {
  from {
    opacity: 0;
    translate: 0 calc(5 * var(--u));
  }
}
.tabs {
  position: absolute;
  left: 0;
  top: calc(34.5 * var(--u));
  display: flex;
}
.tab {
  position: relative;
  display: grid;
  place-items: center;
  width: var(--tab-pitch);
  height: calc(25 * var(--u));
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: calc(4 * var(--u));
  background: none;
  color: var(--cyan-dim);
  cursor: pointer;
  transition: color 160ms ease;
}
.tab:hover {
  color: #2d8fc4;
}
.tab[data-active] {
  color: var(--cyan-hi);
}
.tab:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.tabIcon {
  display: block;
}
.tab[data-active] .tabIcon {
  filter: drop-shadow(0 0 calc(3 * var(--u)) rgb(40 220 255 / 0.85));
  animation: tab-pop 320ms cubic-bezier(0.3, 1.6, 0.5, 1) both;
}
@keyframes tab-pop {
  from {
    scale: 0.8;
  }
}
.tabSvg {
  display: block;
  width: calc(25 * var(--u));
  height: calc(25 * var(--u));
}
.dots {
  position: absolute;
  left: 50%;
  top: calc(-5.5 * var(--u));
  display: flex;
  gap: calc(2.4 * var(--u));
  translate: -50% 0;
}
.dot {
  width: calc(2.4 * var(--u));
  height: calc(2.4 * var(--u));
  border-radius: 50%;
  background: var(--cyan-dim);
  transition: background-color 200ms ease;
}
.dot[data-on] {
  background: #b4f4ff;
}
.rail {
  position: absolute;
  left: 0;
  right: 0;
  top: calc(69 * var(--u));
  height: 0;
}
.rail::before,
.railLit {
  content: '';
  position: absolute;
  left: 0;
  top: calc(-0.6 * var(--u));
  height: calc(1.2 * var(--u));
  border-radius: 1px;
}
.rail::before {
  right: 0;
  background: var(--rail);
}
.railLit {
  width: calc(var(--x) + 32 * var(--u));
  background: #36c3f5;
  box-shadow: 0 0 calc(3 * var(--u)) rgb(54 195 245 / 0.7);
  transition: width 260ms cubic-bezier(0.3, 1.15, 0.5, 1);
}
.railStick {
  position: absolute;
  left: calc(var(--x) + 30 * var(--u));
  top: calc(-8.6 * var(--u));
  transition: left 260ms cubic-bezier(0.3, 1.15, 0.5, 1);
}
.stickSvg {
  display: block;
  width: calc(29 * var(--u));
  height: calc(22.2 * var(--u));
}

/* ---- The grid of frames ---- */

.grid {
  position: relative;
  display: grid;
  grid-template-columns: repeat(var(--cols), var(--cell-size));
  grid-auto-rows: var(--cell-size);
  gap: calc(22 * var(--u)) calc(14 * var(--u));
  justify-content: center;
  width: fit-content;
  margin: 0 auto;
  outline: none;
}
.grid:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: calc(8 * var(--u));
  border-radius: calc(4 * var(--u));
}
.cell {
  position: relative;
  cursor: pointer;
}
.grid[data-turn='next'] .cell {
  animation: turn-next 300ms cubic-bezier(0.2, 0.8, 0.3, 1) both;
  animation-delay: calc(var(--col) * 16ms);
}
.grid[data-turn='prev'] .cell {
  animation: turn-prev 300ms cubic-bezier(0.2, 0.8, 0.3, 1) both;
  animation-delay: calc((var(--cols) - 1 - var(--col)) * 16ms);
}
@keyframes turn-next {
  from {
    opacity: 0;
    translate: calc(30 * var(--u)) 0;
  }
}
@keyframes turn-prev {
  from {
    opacity: 0;
    translate: calc(-30 * var(--u)) 0;
  }
}

/* A frame: a light outer line, a dark gap, an inner line, a cyan glow inside, chamfered corners. */
.frame {
  position: absolute;
  inset: 0;
  box-sizing: border-box;
  overflow: hidden;
  border: calc(1.3 * var(--u)) solid var(--frame-line);
  background: radial-gradient(closest-side, var(--cell) 55%, #082031 130%);
  box-shadow:
    inset 0 0 0 calc(2.2 * var(--u)) #0a0f1a,
    inset 0 0 0 calc(3.4 * var(--u)) var(--frame-inner),
    inset 0 0 calc(12 * var(--u)) calc(4 * var(--u)) var(--cell-glow);
  transition:
    border-color 140ms ease,
    box-shadow 140ms ease;
}
.frame::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 2;
  background:
    linear-gradient(135deg, var(--corner, var(--frame-line)) calc(4.2 * var(--u)), transparent 0)
      top left / calc(9 * var(--u)) calc(9 * var(--u)) no-repeat,
    linear-gradient(225deg, var(--corner, var(--frame-line)) calc(4.2 * var(--u)), transparent 0)
      top right / calc(9 * var(--u)) calc(9 * var(--u)) no-repeat,
    linear-gradient(45deg, var(--corner, var(--frame-line)) calc(4.2 * var(--u)), transparent 0)
      bottom left / calc(9 * var(--u)) calc(9 * var(--u)) no-repeat,
    linear-gradient(315deg, var(--corner, var(--frame-line)) calc(4.2 * var(--u)), transparent 0)
      bottom right / calc(9 * var(--u)) calc(9 * var(--u)) no-repeat;
  pointer-events: none;
}
.cell:hover .frame {
  border-color: #b9c4d2;
}
.cell[data-selected] .frame {
  --corner: var(--frame-sel);
  border-color: var(--frame-sel);
  box-shadow:
    inset 0 0 0 calc(2.2 * var(--u)) #0a0f1a,
    inset 0 0 0 calc(3.4 * var(--u)) #d7e9f8,
    inset 0 0 calc(12 * var(--u)) calc(4 * var(--u)) rgb(60 190 255 / 0.55),
    0 0 calc(10 * var(--u)) rgb(80 200 255 / 0.35);
}
.picture {
  position: absolute;
  inset: calc(3.4 * var(--u));
  display: block;
}
.art {
  display: block;
  width: 100%;
  height: 100%;
}
.cell[data-status='sighted'] .picture {
  inset: calc(10 * var(--u));
}
.art [data-silhouette] *,
.bigArt [data-silhouette] * {
  fill: var(--silhouette) !important;
  stroke: var(--silhouette) !important;
}
.lens {
  position: absolute;
  left: 50%;
  top: calc(24 * var(--u));
  width: calc(34 * var(--u));
  height: calc(34 * var(--u));
  color: var(--lens);
  translate: -50% 0;
}
.number {
  position: absolute;
  z-index: 3;
  left: calc(7 * var(--u));
  bottom: calc(4 * var(--u));
  font-size: max(8px, calc(12.5 * var(--u)));
  font-style: italic;
  font-weight: 500;
  line-height: 1;
  color: #45c2f5;
  text-shadow: 0 0 calc(3 * var(--u)) #000;
}
.cell[data-status='registered'] .number {
  color: #e9fbff;
  text-shadow:
    0 0 calc(2 * var(--u)) #000,
    0 calc(1 * var(--u)) calc(2 * var(--u)) rgb(0 0 0 / 0.8);
}
.caption {
  position: absolute;
  left: calc(-6 * var(--u));
  right: calc(-6 * var(--u));
  top: calc(100% + 4 * var(--u));
  display: flex;
  justify-content: center;
  height: calc(13 * var(--u));
}
.script {
  display: block;
  width: calc(var(--ratio) * 8 * var(--u));
  height: calc(8 * var(--u));
  max-width: 100%;
  margin-top: calc(1.5 * var(--u));
  color: var(--script);
}
.name {
  font-size: max(9px, calc(13 * var(--u)));
  font-style: italic;
  font-weight: 500;
  line-height: calc(13 * var(--u));
  white-space: nowrap;
  color: #48c6ff;
  animation: word-in 200ms cubic-bezier(0.2, 0.8, 0.3, 1) both;
}

/* Registering: a shutter flash, and the picture develops from white with a pop. */
.picture[data-flash] {
  animation: develop 1100ms cubic-bezier(0.2, 0.7, 0.3, 1) both;
}
@keyframes develop {
  0% {
    filter: brightness(3) saturate(0);
    scale: 0.86;
  }
  30% {
    scale: 1.05;
  }
  55% {
    scale: 1;
  }
  100% {
    filter: none;
  }
}
.shutter {
  position: absolute;
  inset: 0;
  z-index: 4;
  background: #fff;
  box-shadow: 0 0 calc(24 * var(--u)) calc(8 * var(--u)) rgb(160 230 255 / 0.9);
  opacity: 0;
  pointer-events: none;
  animation: shutter 700ms ease-out;
}
@keyframes shutter {
  0% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}

/* The cursor: four cyan corner marks pointing out, gliding over the frames and breathing. */
.cursor {
  position: absolute;
  left: 0;
  top: 0;
  z-index: 5;
  width: var(--cell-size);
  height: var(--cell-size);
  pointer-events: none;
  transform: translate(calc(var(--col) * var(--pitch-x)), calc(var(--row) * var(--pitch-y)));
  transition: transform 160ms cubic-bezier(0.3, 1.3, 0.55, 1);
}
.grid[data-turn] .cursor {
  animation: cursor-in 260ms cubic-bezier(0.3, 1.4, 0.5, 1) 100ms both;
}
@keyframes cursor-in {
  from {
    opacity: 0;
    scale: 1.2;
  }
}
.cursor span {
  position: absolute;
  width: calc(11 * var(--u));
  height: calc(11 * var(--u));
  background: #3fc8ff;
  filter: drop-shadow(0 0 calc(2.5 * var(--u)) rgb(63 200 255 / 0.9));
  animation: corner-breathe 1.4s ease-in-out infinite;
}
.cursor span[data-corner='tl'] {
  left: calc(-4 * var(--u));
  top: calc(-4 * var(--u));
  clip-path: polygon(0 0, 100% 0, 58% 22%, 22% 58%, 0 100%);
  --out: -1;
  --down: -1;
}
.cursor span[data-corner='tr'] {
  right: calc(-4 * var(--u));
  top: calc(-4 * var(--u));
  clip-path: polygon(100% 0, 100% 100%, 78% 58%, 42% 22%, 0 0);
  --out: 1;
  --down: -1;
}
.cursor span[data-corner='bl'] {
  left: calc(-4 * var(--u));
  bottom: calc(-4 * var(--u));
  clip-path: polygon(0 100%, 0 0, 22% 42%, 58% 78%, 100% 100%);
  --out: -1;
  --down: 1;
}
.cursor span[data-corner='br'] {
  right: calc(-4 * var(--u));
  bottom: calc(-4 * var(--u));
  clip-path: polygon(100% 100%, 0 100%, 42% 78%, 78% 42%, 100% 0);
  --out: 1;
  --down: 1;
}
@keyframes corner-breathe {
  50% {
    translate: calc(var(--out) * 1.6 * var(--u)) calc(var(--down) * 1.6 * var(--u));
  }
}

/* ---- Page and entry arrows ---- */

.arrow {
  position: absolute;
  z-index: 2;
  top: calc(398 * var(--u));
  display: grid;
  place-items: center;
  width: calc(19 * var(--u));
  height: calc(44 * var(--u));
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: calc(4 * var(--u));
  background: none;
  color: #12d7fa;
  cursor: pointer;
}
.arrow[hidden] {
  display: none;
}
.arrow[data-side='prev'] {
  left: calc(1 * var(--u));
}
.arrow[data-side='next'] {
  right: calc(1 * var(--u));
}
@container (min-width: 720px) {
  .arrow {
    top: calc(226 * var(--u));
  }
  .arrow[data-side='prev'] {
    left: calc(50 * var(--u));
  }
  .arrow[data-side='next'] {
    right: calc(50 * var(--u));
  }
}
.detailView .arrow {
  top: calc(116 * var(--u));
}
@container (min-width: 720px) {
  .detailView .arrow {
    top: calc(226 * var(--u));
  }
}
.arrow:hover {
  color: #7febff;
}
.arrow:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.arrowGlyph {
  display: block;
  filter: drop-shadow(0 0 calc(4 * var(--u)) rgb(18 215 250 / 0.8));
}
.arrow[data-side='prev'] .arrowGlyph {
  scale: -1 1;
}
.arrowGlyph[data-hit] {
  animation: nudge 260ms cubic-bezier(0.3, 1.4, 0.5, 1);
}
@keyframes nudge {
  40% {
    translate: calc(6 * var(--u)) 0;
  }
}
.arrowSvg {
  display: block;
  width: calc(13 * var(--u));
  height: calc(32 * var(--u));
}
@container (min-width: 720px) {
  .arrow {
    width: calc(26 * var(--u));
    height: calc(52 * var(--u));
  }
  .arrowSvg {
    width: calc(18 * var(--u));
    height: calc(44 * var(--u));
  }
}

/* ---- The sensor in the lower right corner ---- */

.sensor {
  position: absolute;
  z-index: 2;
  right: calc(26 * var(--u));
  bottom: calc(16 * var(--u));
  display: flex;
  align-items: center;
  gap: calc(7 * var(--u));
}
@container (min-width: 720px) {
  .sensor {
    right: calc(80 * var(--u));
    bottom: calc(22 * var(--u));
  }
}
.sensorText {
  display: grid;
  justify-items: end;
}
.sensorName {
  font-size: max(9.5px, calc(14.5 * var(--u)));
  line-height: calc(17 * var(--u));
  white-space: nowrap;
  color: var(--cyan);
  animation: word-in 240ms cubic-bezier(0.2, 0.8, 0.3, 1) both;
}
.sensor:not([data-on]) .sensorName {
  color: var(--cyan-dim);
}
.sensorScript {
  display: block;
  width: calc(var(--ratio) * 6.5 * var(--u));
  height: calc(6.5 * var(--u));
  margin-top: calc(1 * var(--u));
  color: var(--script);
}
.sensorIcon {
  position: relative;
  display: block;
}
.sensorSvg {
  display: block;
  width: calc(23 * var(--u));
  height: calc(23 * var(--u));
  transition: opacity 200ms ease;
}
.sensor:not([data-on]) .sensorSvg {
  opacity: 0.45;
}
.sensor[data-on] .sensorSvg {
  filter: drop-shadow(0 0 calc(4 * var(--u)) rgb(110 220 255 / 0.8));
  animation: sensor-pulse 1.8s ease-in-out infinite;
}
@keyframes sensor-pulse {
  50% {
    filter: drop-shadow(0 0 calc(7 * var(--u)) rgb(110 220 255 / 1));
  }
}
.ping span {
  position: absolute;
  inset: 0;
  border: calc(1.5 * var(--u)) solid #8fe4ff;
  border-radius: 50%;
  opacity: 0;
  pointer-events: none;
  animation: ping 900ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
.ping span + span {
  animation-delay: 220ms;
}
@keyframes ping {
  from {
    opacity: 1;
    scale: 1;
  }
  to {
    opacity: 0;
    scale: 2.6;
  }
}

/* ---- The detail page ---- */

.detail {
  position: relative;
  display: grid;
  justify-items: center;
  gap: calc(22 * var(--u));
  padding: calc(18 * var(--u)) calc(21 * var(--u)) calc(64 * var(--u));
}
.dLeft {
  display: grid;
  justify-items: center;
  width: calc(233 * var(--u));
}
.dRight {
  display: grid;
  gap: calc(14 * var(--u));
  width: calc(370 * var(--u));
}
@container (min-width: 720px) {
  .detail {
    position: absolute;
    inset: 0;
    display: block;
    padding: 0;
  }
  .dLeft {
    position: absolute;
    left: calc(224 * var(--u));
    top: calc(62 * var(--u));
  }
  .dRight {
    position: absolute;
    left: calc(483 * var(--u));
    top: calc(73 * var(--u));
    display: block;
    width: calc(309 * var(--u));
  }
  .section[data-slot='description'] {
    height: calc(178 * var(--u));
  }
  .section[data-slot='locations'] {
    height: calc(79 * var(--u));
  }
}
.dTitle {
  margin: 0 0 calc(5 * var(--u));
  font-size: max(10px, calc(14 * var(--u)));
  font-style: italic;
  font-weight: 600;
  line-height: calc(18 * var(--u));
  white-space: nowrap;
  color: #48c6ff;
}
.big {
  position: relative;
  box-sizing: border-box;
  width: calc(223 * var(--u));
  height: calc(222 * var(--u));
  border: calc(1.6 * var(--u)) solid #e9f3ff;
  background: #070c18;
  transform-origin: 0 0;
  box-shadow: 0 0 calc(12 * var(--u)) rgb(80 190 255 / 0.35);
}
.big::after {
  content: '';
  position: absolute;
  inset: calc(-4.6 * var(--u));
  background:
    linear-gradient(#e9f3ff, #e9f3ff) top left / calc(6 * var(--u)) calc(6 * var(--u)) no-repeat,
    linear-gradient(#e9f3ff, #e9f3ff) top right / calc(6 * var(--u)) calc(6 * var(--u)) no-repeat,
    linear-gradient(#e9f3ff, #e9f3ff) bottom left / calc(6 * var(--u)) calc(6 * var(--u)) no-repeat,
    linear-gradient(#e9f3ff, #e9f3ff) bottom right / calc(6 * var(--u)) calc(6 * var(--u)) no-repeat;
  pointer-events: none;
}
.bigInner {
  position: absolute;
  inset: calc(3.4 * var(--u));
  display: grid;
  place-items: center;
  overflow: hidden;
  outline: calc(1.2 * var(--u)) solid #c9def2;
  background: #050b14;
}
.bigArt {
  display: block;
  width: 100%;
  height: 100%;
}
.bigLens {
  width: calc(80 * var(--u));
  height: calc(80 * var(--u));
  color: var(--lens);
}
.section {
  margin: 0;
}
.section[data-slot='stat'] {
  width: 100%;
  margin-top: calc(24 * var(--u));
}
.rule {
  display: flex;
  align-items: center;
  height: calc(16 * var(--u));
  margin: 0;
  font-size: max(9px, calc(13 * var(--u)));
  font-style: italic;
  font-weight: 500;
  white-space: nowrap;
  color: var(--cyan);
}
.ruleEnd {
  flex: none;
  width: calc(14 * var(--u));
  height: calc(8 * var(--u));
  color: #36b4ea;
}
.ruleEnd[data-flip] {
  scale: -1 1;
}
.ruleLine {
  flex: 1;
  height: calc(1.3 * var(--u));
  background: #36b4ea;
}
.ruleText {
  padding: 0 calc(7 * var(--u));
}
.text {
  margin: calc(2 * var(--u)) 0 0;
  padding: 0 calc(10 * var(--u)) 0 calc(12 * var(--u));
  font-size: max(10px, calc(13.5 * var(--u)));
  font-style: italic;
  font-weight: 500;
  line-height: 1.19;
  color: var(--cyan);
}
.places {
  margin: calc(-3 * var(--u)) 0 0;
  padding: 0 0 0 calc(12 * var(--u));
  list-style: none;
  font-size: max(10px, calc(13.5 * var(--u)));
  font-style: italic;
  font-weight: 500;
  line-height: calc(22 * var(--u));
  color: var(--cyan);
}
.statLine,
.effect {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: calc(5 * var(--u));
  min-height: calc(18 * var(--u));
  margin: calc(6 * var(--u)) 0 0;
  font-size: max(10px, calc(13.5 * var(--u)));
  font-style: italic;
  font-weight: 500;
  color: var(--cyan);
}
.dim {
  color: var(--cyan-dim);
}
.hearts,
.marks {
  display: flex;
  gap: calc(3 * var(--u));
}
.heartSvg {
  display: block;
  width: calc(15 * var(--u));
  height: calc(15 * var(--u));
}
.markSvg {
  display: block;
  width: calc(15 * var(--u));
  height: calc(15 * var(--u));
  color: #233c66;
}
.markSvg[data-on] {
  color: #ff6b5a;
}
.value {
  display: inline-flex;
  align-items: center;
  gap: calc(5 * var(--u));
  font-style: normal;
  font-variant-numeric: tabular-nums;
  color: #e9fbff;
}
.valueSvg {
  width: calc(14 * var(--u));
  height: calc(14 * var(--u));
  color: #f2c33c;
}
.effectSvg {
  width: calc(15 * var(--u));
  height: calc(15 * var(--u));
  color: #f4f8ff;
}

/* Opening: rules draw out from their titles, the text rises, row by row. */
.tablet[data-open] .ruleLine[data-side='start'] {
  transform-origin: right;
  animation: draw 360ms cubic-bezier(0.3, 0.8, 0.3, 1) 140ms both;
}
.tablet[data-open] .ruleLine[data-side='end'] {
  transform-origin: left;
  animation: draw 360ms cubic-bezier(0.3, 0.8, 0.3, 1) 140ms both;
}
@keyframes draw {
  from {
    scale: 0 1;
  }
}
.tablet[data-open] .ruleEnd {
  animation: fade 300ms ease 300ms both;
}
@keyframes fade {
  from {
    opacity: 0;
  }
}
.tablet[data-open] .dTitle,
.tablet[data-open] .section {
  animation: rise 320ms cubic-bezier(0.2, 0.8, 0.3, 1) both;
}
.tablet[data-open] .dTitle {
  animation-delay: 160ms;
}
.tablet[data-open] .section[data-slot='description'] {
  animation-delay: 120ms;
}
.tablet[data-open] .section[data-slot='locations'] {
  animation-delay: 190ms;
}
.tablet[data-open] .section[data-slot='effect'],
.tablet[data-open] .section[data-slot='stat'] {
  animation-delay: 260ms;
}
@keyframes rise {
  from {
    opacity: 0;
    translate: 0 calc(8 * var(--u));
  }
}
/* Closing: everything but the picture fades at once; the picture flies home. */
.tablet:not([data-open]) .dTitle,
.tablet:not([data-open]) .section,
.tablet:not([data-open]) .detailView .arrow {
  opacity: 0;
  transition: opacity 120ms ease;
}

/* Stepping: the old entry slides off, the next slides in from the side pressed. */
.tablet[data-open] .detail [data-dir='next'] {
  animation: step-next 300ms cubic-bezier(0.2, 0.8, 0.3, 1) both;
  animation-delay: 0ms;
}
.tablet[data-open] .detail [data-dir='prev'] {
  animation: step-prev 300ms cubic-bezier(0.2, 0.8, 0.3, 1) both;
  animation-delay: 0ms;
}
.tablet[data-open] .detail [data-dir] .section,
.tablet[data-open] .detail [data-dir] .ruleLine,
.tablet[data-open] .detail [data-dir] .ruleEnd {
  animation: none;
}
@keyframes step-next {
  from {
    opacity: 0;
    translate: calc(24 * var(--u)) 0;
  }
}
@keyframes step-prev {
  from {
    opacity: 0;
    translate: calc(-24 * var(--u)) 0;
  }
}

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

/* ---- Reduced motion: every change lands at once ---- */

.root[data-reduced] *,
.root[data-reduced] *::before,
.root[data-reduced] *::after {
  animation: none !important;
  transition: none !important;
}
```

### creature-compendium/entries.ts

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

/*
 * The compendium's data: five categories and the 96 entries of the sample guide. An entry is
 * numbered by its place in the whole list (001…096); a category starts on a new page, and a page
 * holds 24 entries. Only registered entries carry a description, places and a stat; a sighted one
 * shows as a silhouette, an unknown one as an empty frame with its name in the tablet's script.
 */

export type Category = 'creatures' | 'monsters' | 'materials' | 'equipment' | 'treasures';

export type Status = 'registered' | 'sighted' | 'unknown';

/** The picture drawn for an entry (see art.tsx). */
export type Shape =
  | 'fox'
  | 'deer'
  | 'boar'
  | 'goat'
  | 'hare'
  | 'songbird'
  | 'heron'
  | 'fish'
  | 'crab'
  | 'frog'
  | 'beetle'
  | 'butterfly'
  | 'blob'
  | 'bat'
  | 'brute'
  | 'golem'
  | 'mushroom'
  | 'fruit'
  | 'herb'
  | 'crystal'
  | 'egg'
  | 'sword'
  | 'shield'
  | 'bow'
  | 'axe'
  | 'chest'
  | 'amulet';

/** The setting behind the picture. */
export type Habitat = 'meadow' | 'forest' | 'coast' | 'snow' | 'desert' | 'cave' | 'ruins';

/** Three colours: the body, a darker second, an accent. */
export type Palette = [string, string, string];

export type EffectIcon = 'attack' | 'defense' | 'speed' | 'heat' | 'cold' | 'glow' | 'stealth';

export type Stat =
  | { kind: 'hearts'; value: number }
  | { kind: 'threat'; value: number }
  | { kind: 'attack'; value: number }
  | { kind: 'defense'; value: number }
  | { kind: 'value'; value: number };

export type CompendiumEntry = {
  id: string;
  name: string;
  category: Category;
  status: Status;
  shape: Shape;
  palette: Palette;
  habitat: Habitat;
  description?: string;
  locations?: string[];
  stat?: Stat;
  /** What it gives or holds: an icon and a few words ("Attack up", "Horn, Fang"). */
  effect?: { icon?: EffectIcon; label: string };
};

export const CATEGORIES: { id: Category; label: string; effectTitle: string }[] = [
  { id: 'creatures', label: 'Creatures', effectTitle: 'Cooking Effect' },
  { id: 'monsters', label: 'Monsters', effectTitle: 'Drops' },
  { id: 'materials', label: 'Materials', effectTitle: 'Cooking Effect' },
  { id: 'equipment', label: 'Equipment', effectTitle: 'Bonus' },
  { id: 'treasures', label: 'Treasures', effectTitle: 'Contents' },
];

export const STAT_TITLE: Record<Stat['kind'], string> = {
  hearts: 'Hearts Recovered',
  threat: 'Threat',
  attack: 'Attack',
  defense: 'Defense',
  value: 'Value',
};

export const PAGE_SIZE = 24;

// Palettes, reused across entries.
const P = {
  ember: ['#e2733a', '#7a3418', '#fff4e6'],
  frost: ['#e6eef6', '#8aa3bd', '#5fb7e8'],
  tan: ['#c49a68', '#76563a', '#f3e6d2'],
  sand: ['#dcc08e', '#9c7c4c', '#fff7e8'],
  rust: ['#a65a32', '#5b2f1a', '#f2e2c8'],
  moss: ['#7d8c4e', '#47502a', '#e8e2b8'],
  slate: ['#6b6f78', '#3a3d45', '#d8d4cc'],
  ash: ['#8b8580', '#4a4542', '#e0d8cf'],
  wool: ['#ece4d4', '#9a8e78', '#4a4238'],
  lark: ['#b88350', '#6a4426', '#f6e2b8'],
  sun: ['#f2c33c', '#9a6a12', '#fff6c8'],
  sky: ['#5fb7e8', '#215e8a', '#ffffff'],
  dusk: ['#9b6ad0', '#4b2c74', '#f6d85c'],
  reed: ['#9fb4c4', '#55697a', '#f2c33c'],
  trout: ['#9fc6a6', '#4a6e58', '#e8806a'],
  red: ['#d5503c', '#7a2418', '#ffe2c8'],
  green: ['#6fc25a', '#2f6e2a', '#f2f6a0'],
  teal: ['#47b8a8', '#1d5e58', '#e6fff8'],
  steel: ['#c7ced6', '#69737e', '#f2c33c'],
  oak: ['#a8743e', '#5e3c1c', '#d9d2c4'],
  gold: ['#f0c24a', '#8c6414', '#5fd1f0'],
  violet: ['#7b5cc8', '#3c2a6e', '#ffd75e'],
  bone: ['#e8dcc0', '#8e7e5e', '#d5503c'],
  pink: ['#e98bb0', '#8e3b60', '#fff2f6'],
} satisfies Record<string, Palette>;

type Row = [
  name: string,
  status: 'R' | 'S' | 'U',
  shape: Shape,
  palette: Palette,
  habitat: Habitat,
  details?: Pick<CompendiumEntry, 'description' | 'locations' | 'stat' | 'effect'>,
];

const STATUS = { R: 'registered', S: 'sighted', U: 'unknown' } as const;

const ROWS: Record<Category, Row[]> = {
  creatures: [
    [
      'Ember Fox',
      'R',
      'fox',
      P.ember,
      'meadow',
      {
        description:
          'A quick, curious fox with a coat the colour of coals. It shadows travellers at a distance, hoping they drop something to eat, and bolts at the first sound of a bowstring.',
        locations: ['Amber Fields', 'Lantern Hills'],
        stat: { kind: 'hearts', value: 1 },
        effect: { icon: 'heat', label: 'Warmth' },
      },
    ],
    ['Frost Fox', 'S', 'fox', P.frost, 'snow'],
    [
      'Meadow Hare',
      'R',
      'hare',
      P.tan,
      'meadow',
      {
        description:
          'Common across open grassland. It freezes when it senses danger, trusting its coat to hide it, then breaks into a zigzag run too fast to follow.',
        locations: ['Amber Fields', 'Willow Downs'],
        stat: { kind: 'hearts', value: 2 },
        effect: { icon: 'speed', label: 'Speed Up' },
      },
    ],
    ['Dune Hare', 'U', 'hare', P.sand, 'desert'],
    [
      'Red Stag',
      'R',
      'deer',
      P.rust,
      'forest',
      {
        description:
          'The largest deer of the old woods. Each autumn the stags clash antlers in the clearings, and the sound carries for miles through the trees.',
        locations: ['Hollow Wood', 'Mistfall Glade'],
        stat: { kind: 'hearts', value: 3 },
        effect: { icon: 'defense', label: 'Defense Up' },
      },
    ],
    ['Moss Deer', 'U', 'deer', P.moss, 'forest'],
    [
      'Ridge Boar',
      'R',
      'boar',
      P.slate,
      'forest',
      {
        description:
          'A stocky boar with a temper to match. It digs for roots along the forest edge and will charge anything that comes between it and its meal.',
        locations: ['Hollow Wood', 'Stonebrook'],
        stat: { kind: 'hearts', value: 3 },
        effect: { icon: 'attack', label: 'Attack Up' },
      },
    ],
    ['Ash Boar', 'U', 'boar', P.ash, 'cave'],
    [
      'Lark Finch',
      'R',
      'songbird',
      P.lark,
      'meadow',
      {
        description:
          'A small brown songbird that sings from fence posts at dawn. Flocks lift all at once when startled and settle again a few trees away.',
        locations: ['Willow Downs', 'Amber Fields'],
        stat: { kind: 'hearts', value: 1 },
      },
    ],
    ['Ember Finch', 'U', 'songbird', P.ember, 'desert'],
    ['Snow Bunting', 'S', 'songbird', P.frost, 'snow'],
    [
      'Cliff Goat',
      'R',
      'goat',
      P.wool,
      'meadow',
      {
        description:
          'A sure-footed goat that lives on ledges no predator can reach. Its thick fleece keeps it warm through the coldest nights on the heights.',
        locations: ['Greyhorn Slopes', 'Lantern Hills'],
        stat: { kind: 'hearts', value: 2 },
        effect: { icon: 'cold', label: 'Cold Resist' },
      },
    ],
    ['Horned Ram', 'U', 'goat', P.tan, 'snow'],
    [
      'Reed Heron',
      'R',
      'heron',
      P.reed,
      'coast',
      {
        description:
          'A patient wader of the shallows. It stands perfectly still for minutes at a time, then strikes faster than the eye can follow.',
        locations: ['Saltmarsh Bay', 'Reedwater'],
        stat: { kind: 'hearts', value: 1 },
      },
    ],
    ['Ash Heron', 'U', 'heron', P.ash, 'coast'],
    [
      'Glimmer Trout',
      'R',
      'fish',
      P.trout,
      'coast',
      {
        description:
          'Its scales flash in fast water like scattered coins. Eaten fresh, it leaves a pleasant tingle that keeps the body light on its feet.',
        locations: ['Stonebrook', 'Reedwater'],
        stat: { kind: 'hearts', value: 2 },
        effect: { icon: 'speed', label: 'Speed Up' },
      },
    ],
    ['Spined Perch', 'U', 'fish', P.red, 'coast'],
    ['Bluefin Darter', 'S', 'fish', P.sky, 'coast'],
    [
      'Shore Crab',
      'R',
      'crab',
      P.red,
      'coast',
      {
        description:
          'Scuttles sideways along the tide line. Its shell hardens in the sun, and cooks who know the trick say it toughens whoever eats it too.',
        locations: ['Saltmarsh Bay', 'Gull Point'],
        stat: { kind: 'hearts', value: 1 },
        effect: { icon: 'defense', label: 'Defense Up' },
      },
    ],
    ['Ironshell Crab', 'U', 'crab', P.slate, 'cave'],
    [
      'Pond Frog',
      'R',
      'frog',
      P.green,
      'forest',
      {
        description:
          'Fills the evening with its croaking from every pond and ditch. Its sticky skin helps it cling to wet stone, and climbers prize it for that.',
        locations: ['Reedwater', 'Mistfall Glade'],
        stat: { kind: 'hearts', value: 1 },
        effect: { icon: 'stealth', label: 'Stealth Up' },
      },
    ],
    ['Ember Toad', 'U', 'frog', P.ember, 'desert'],
    [
      'Sun Beetle',
      'R',
      'beetle',
      P.sun,
      'meadow',
      {
        description:
          'A beetle with a shell that holds the day’s warmth long after dark. Children keep them in jars as tiny lanterns.',
        locations: ['Amber Fields'],
        stat: { kind: 'hearts', value: 0 },
        effect: { icon: 'glow', label: 'Glow' },
      },
    ],
    ['Rhino Beetle', 'U', 'beetle', P.slate, 'forest'],
    [
      'Sky Butterfly',
      'R',
      'butterfly',
      P.sky,
      'meadow',
      {
        description:
          'Drifts over flowering meadows in loose clouds of blue. Its wing dust is said to calm the nerves of anyone who breathes it in.',
        locations: ['Willow Downs', 'Amber Fields'],
        stat: { kind: 'hearts', value: 0 },
        effect: { icon: 'stealth', label: 'Calm' },
      },
    ],
    ['Ember Moth', 'U', 'butterfly', P.ember, 'cave'],
    [
      'Dusk Butterfly',
      'S',
      'butterfly',
      P.dusk,
      'forest',
      {
        description:
          'It only takes wing in the half light after sunset, gold spots glowing on violet wings. Few travellers ever see one up close.',
        locations: ['Mistfall Glade'],
        stat: { kind: 'hearts', value: 0 },
        effect: { icon: 'glow', label: 'Night Sight' },
      },
    ],
    ['Gilded Beetle', 'U', 'beetle', P.gold, 'desert'],
    ['Mire Frog', 'U', 'frog', P.moss, 'forest'],
    ['Woolly Ram', 'U', 'goat', P.ash, 'meadow'],
    ['Night Stag', 'U', 'deer', P.slate, 'snow'],
    ['Silver Fox', 'U', 'fox', P.ash, 'snow'],
  ],
  monsters: [
    [
      'Bog Slime',
      'R',
      'blob',
      P.green,
      'cave',
      {
        description:
          'A wobbling lump of swamp jelly that swallows whatever it rolls over. Harmless alone, but where there is one, there are always more.',
        locations: ['Reedwater', 'Sunken Halls'],
        stat: { kind: 'threat', value: 1 },
        effect: { label: 'Slime Jelly' },
      },
    ],
    [
      'Ember Slime',
      'R',
      'blob',
      P.ember,
      'desert',
      {
        description:
          'Burns hot enough to scorch the ground it crosses. Strike it from range, or wait for the rain to put it out.',
        locations: ['Cinder Flats'],
        stat: { kind: 'threat', value: 2 },
        effect: { icon: 'heat', label: 'Ember Jelly' },
      },
    ],
    ['Frost Slime', 'U', 'blob', P.frost, 'snow'],
    [
      'Cave Bat',
      'R',
      'bat',
      P.violet,
      'cave',
      {
        description:
          'Roosts by the hundred in the dark and swarms anything carrying a light. Its wings beat too fast to see.',
        locations: ['Sunken Halls', 'Greyhorn Slopes'],
        stat: { kind: 'threat', value: 1 },
        effect: { label: 'Bat Wing' },
      },
    ],
    ['Ember Bat', 'U', 'bat', P.ember, 'desert'],
    ['Ice Bat', 'S', 'bat', P.frost, 'snow'],
    [
      'Horned Brute',
      'R',
      'brute',
      P.red,
      'forest',
      {
        description:
          'A hulking brute that guards its camp with a crude club. It is slow to start but hits hard; keep moving and strike from behind.',
        locations: ['Hollow Wood', 'Stonebrook'],
        stat: { kind: 'threat', value: 3 },
        effect: { label: 'Horn, Fang' },
      },
    ],
    ['Blue Brute', 'U', 'brute', P.sky, 'forest'],
    ['Black Brute', 'U', 'brute', P.slate, 'cave'],
    [
      'Stone Sentinel',
      'R',
      'golem',
      P.ash,
      'ruins',
      {
        description:
          'A guardian of piled stone that wakes when its core is touched by sunlight. Its weak point glows between the blocks of its back.',
        locations: ['Old Watchtower', 'Sunken Halls'],
        stat: { kind: 'threat', value: 4 },
        effect: { label: 'Core Shard' },
      },
    ],
    ['Frost Sentinel', 'U', 'golem', P.frost, 'snow'],
    ['Ember Sentinel', 'U', 'golem', P.ember, 'desert'],
    ['Spore Slime', 'S', 'blob', P.moss, 'forest'],
    ['Shadow Bat', 'U', 'bat', P.slate, 'cave'],
    ['Silver Brute', 'U', 'brute', P.steel, 'snow'],
    ['Ancient Sentinel', 'U', 'golem', P.gold, 'ruins'],
  ],
  materials: [
    [
      'Hearty Cap',
      'R',
      'mushroom',
      P.red,
      'forest',
      {
        description:
          'A fat red mushroom that grows at the foot of old trees. One cooked into a meal restores a surprising amount of strength.',
        locations: ['Hollow Wood', 'Mistfall Glade'],
        stat: { kind: 'hearts', value: 3 },
        effect: { icon: 'defense', label: 'Extra Hearts' },
      },
    ],
    [
      'Ember Cap',
      'R',
      'mushroom',
      P.ember,
      'cave',
      {
        description:
          'It glows faintly orange in damp caves and tastes of pepper. Eaten in the cold, it warms you from the inside.',
        locations: ['Sunken Halls'],
        stat: { kind: 'hearts', value: 1 },
        effect: { icon: 'heat', label: 'Warmth' },
      },
    ],
    ['Chill Cap', 'U', 'mushroom', P.frost, 'snow'],
    ['Swift Cap', 'U', 'mushroom', P.sky, 'forest'],
    [
      'Sun Apple',
      'R',
      'fruit',
      P.red,
      'meadow',
      {
        description:
          'A crisp, sweet apple from trees that grow along every road. Travellers eat them by the handful.',
        locations: ['Amber Fields', 'Willow Downs'],
        stat: { kind: 'hearts', value: 1 },
      },
    ],
    ['Frost Pear', 'U', 'fruit', P.frost, 'snow'],
    ['Dune Melon', 'S', 'fruit', P.green, 'desert'],
    [
      'Wild Berry',
      'R',
      'fruit',
      P.violet,
      'forest',
      {
        description:
          'Small, dark and sharp to taste. Bushes heavy with them line the forest paths at the end of summer.',
        locations: ['Hollow Wood'],
        stat: { kind: 'hearts', value: 1 },
      },
    ],
    [
      'Sweet Mint',
      'R',
      'herb',
      P.green,
      'meadow',
      {
        description:
          'A fragrant herb that fills the air when stepped on. Steeped in hot water, it clears the head and lightens the step.',
        locations: ['Willow Downs', 'Lantern Hills'],
        stat: { kind: 'hearts', value: 1 },
        effect: { icon: 'speed', label: 'Speed Up' },
      },
    ],
    ['Blaze Pepper', 'U', 'herb', P.red, 'desert'],
    ['Silent Fern', 'U', 'herb', P.teal, 'forest'],
    [
      'Swiftleaf',
      'R',
      'herb',
      P.teal,
      'meadow',
      {
        description:
          'Its long leaves bend flat in the wind and spring back at once. Runners chew it before a race.',
        locations: ['Amber Fields'],
        stat: { kind: 'hearts', value: 0 },
        effect: { icon: 'speed', label: 'Speed Up' },
      },
    ],
    [
      'Amber Shard',
      'R',
      'crystal',
      P.gold,
      'cave',
      {
        description:
          'A warm golden crystal broken from cave walls. Smiths grind it into a powder that hardens steel.',
        locations: ['Sunken Halls', 'Greyhorn Slopes'],
        stat: { kind: 'value', value: 30 },
        effect: { icon: 'defense', label: 'Hardening' },
      },
    ],
    ['Sky Quartz', 'U', 'crystal', P.sky, 'ruins'],
    ['Ember Ore', 'U', 'crystal', P.ember, 'cave'],
    ['Frost Crystal', 'S', 'crystal', P.frost, 'snow'],
    [
      'Bird Egg',
      'R',
      'egg',
      P.wool,
      'forest',
      {
        description: 'Found in nests high in the trees. Cooks crack them into almost everything.',
        locations: ['Hollow Wood', 'Willow Downs'],
        stat: { kind: 'hearts', value: 1 },
      },
    ],
    ['Speckled Egg', 'U', 'egg', P.tan, 'coast'],
    ['Golden Egg', 'U', 'egg', P.gold, 'ruins'],
    [
      'Glow Moss',
      'R',
      'herb',
      P.teal,
      'cave',
      {
        description:
          'A soft moss that lights the walls of deep caves pale green. Scraped onto a torch, it burns without smoke.',
        locations: ['Sunken Halls'],
        stat: { kind: 'hearts', value: 0 },
        effect: { icon: 'glow', label: 'Glow' },
      },
    ],
    ['Ghost Cap', 'U', 'mushroom', P.violet, 'cave'],
    ['Starfruit', 'U', 'fruit', P.sun, 'meadow'],
    ['Moon Crystal', 'U', 'crystal', P.violet, 'ruins'],
    ['Ember Egg', 'U', 'egg', P.ember, 'desert'],
  ],
  equipment: [
    [
      'Traveler Sword',
      'R',
      'sword',
      P.steel,
      'ruins',
      {
        description:
          'A plain, honest blade carried by wanderers. It will not win any duels on looks, but it holds an edge.',
        locations: ['Old Watchtower', 'Stonebrook'],
        stat: { kind: 'attack', value: 8 },
      },
    ],
    ['Knight Blade', 'U', 'sword', P.steel, 'ruins'],
    ['Flame Edge', 'S', 'sword', P.ember, 'desert'],
    ['Frost Blade', 'U', 'sword', P.frost, 'snow'],
    [
      'Oak Shield',
      'R',
      'shield',
      P.oak,
      'ruins',
      {
        description:
          'Planks of seasoned oak bound with iron. Light enough to carry all day and strong enough to turn a club.',
        locations: ['Willow Downs'],
        stat: { kind: 'defense', value: 6 },
      },
    ],
    ['Iron Shield', 'U', 'shield', P.steel, 'ruins'],
    ['Mirror Shield', 'U', 'shield', P.gold, 'desert'],
    ['Ember Shield', 'U', 'shield', P.ember, 'cave'],
    [
      'Hunter Bow',
      'R',
      'bow',
      P.oak,
      'forest',
      {
        description:
          'A short bow of bent ash, easy to draw on the move. Favoured by those who hunt the forest edge.',
        locations: ['Hollow Wood'],
        stat: { kind: 'attack', value: 5 },
        effect: { icon: 'speed', label: 'Quick Shot' },
      },
    ],
    ['Long Bow', 'U', 'bow', P.oak, 'ruins'],
    ['Frost Bow', 'U', 'bow', P.frost, 'snow'],
    ['Twin Bow', 'U', 'bow', P.steel, 'ruins'],
    [
      'Woodcutter Axe',
      'R',
      'axe',
      P.steel,
      'forest',
      {
        description:
          'Made for felling trees, but it works just as well on anything that gets in the way.',
        locations: ['Hollow Wood', 'Stonebrook'],
        stat: { kind: 'attack', value: 12 },
      },
    ],
    ['Iron Axe', 'U', 'axe', P.slate, 'ruins'],
    ['Battle Axe', 'U', 'axe', P.steel, 'ruins'],
    ['Ember Axe', 'U', 'axe', P.ember, 'desert'],
  ],
  treasures: [
    [
      'Old Chest',
      'R',
      'chest',
      P.oak,
      'ruins',
      {
        description:
          'A weathered chest left in the ruins long ago. The lock gave way years before you found it.',
        locations: ['Old Watchtower'],
        stat: { kind: 'value', value: 20 },
        effect: { label: 'Coins, Arrows' },
      },
    ],
    ['Sunken Chest', 'U', 'chest', P.teal, 'coast'],
    ['Ice Chest', 'U', 'chest', P.frost, 'snow'],
    ['Golden Chest', 'S', 'chest', P.gold, 'ruins'],
    [
      'Moon Amulet',
      'R',
      'amulet',
      P.violet,
      'ruins',
      {
        description:
          'A silver charm set with a violet stone that brightens at night. Whoever wears it is hard to find in the dark.',
        locations: ['Old Watchtower', 'Mistfall Glade'],
        stat: { kind: 'value', value: 120 },
        effect: { icon: 'stealth', label: 'Stealth Up' },
      },
    ],
    ['Sun Amulet', 'U', 'amulet', P.sun, 'desert'],
    ['Tide Amulet', 'U', 'amulet', P.teal, 'coast'],
    ['Star Amulet', 'U', 'amulet', P.gold, 'ruins'],
  ],
};

export const DEFAULT_ENTRIES: CompendiumEntry[] = CATEGORIES.flatMap(({ id: category }) =>
  ROWS[category].map(([name, status, shape, palette, habitat, details]) => ({
    id: name.toLowerCase().replace(/\s+/g, '-'),
    name,
    category,
    status: STATUS[status],
    shape,
    palette,
    habitat,
    ...details,
  })),
);

export type Page = { category: Category; index: number; entries: CompendiumEntry[]; first: number };

/** The entries split into pages: each category starts a new page of at most PAGE_SIZE. */
export function paginate(entries: CompendiumEntry[]): Page[] {
  const pages: Page[] = [];
  let number = 0;
  for (const { id } of CATEGORIES) {
    const own = entries.filter((entry) => entry.category === id);
    for (let start = 0; start < own.length; start += PAGE_SIZE) {
      pages.push({
        category: id,
        index: start / PAGE_SIZE,
        entries: own.slice(start, start + PAGE_SIZE),
        first: number + start,
      });
    }
    number += own.length;
  }
  return pages;
}

export const pad3 = (n: number) => String(n).padStart(3, '0');
```

### creature-compendium/art.tsx

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

import type { CSSProperties, ReactNode, SVGProps } from 'react';
import type { Category, EffectIcon, Habitat, Palette, Shape } from './entries';

/*
 * All the compendium's art, drawn for it as SVG: the entry pictures (a setting and a subject in a
 * 100-unit box, flat colours with a thin dark outline, so they read as small "photos"), the tab and
 * effect icons, the empty-frame lens, the tablet's script, the controller glyphs and the bezel.
 * A sighted entry draws the same subject with every fill and stroke forced to one colour by CSS
 * (the `silhouette` class), so no second set of drawings is needed.
 */

const INK = '#1d1a22';
const line = {
  stroke: INK,
  strokeWidth: 1.4,
  strokeLinejoin: 'round',
  strokeLinecap: 'round',
} as const;

// ---- Settings ----

function Scene({ habitat, id }: { habitat: Habitat; id: string }) {
  const sky = `${id}-sky`;
  const skies: Record<Habitat, [string, string]> = {
    meadow: ['#9fd2ec', '#e2f2f4'],
    forest: ['#b4d4c0', '#e3efe0'],
    coast: ['#92cdee', '#e4f4fb'],
    snow: ['#bcd2e8', '#eef4fa'],
    desert: ['#f1cf98', '#fbecd2'],
    cave: ['#25222e', '#433d4c'],
    ruins: ['#c4cfd8', '#eceeea'],
  };
  const [top, bottom] = skies[habitat];
  return (
    <g>
      <defs>
        <linearGradient id={sky} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor={top} />
          <stop offset="0.7" stopColor={bottom} />
        </linearGradient>
      </defs>
      <rect width="100" height="100" fill={`url(#${sky})`} />
      {habitat === 'meadow' && (
        <>
          <path d="M0 60 Q22 46 44 56 T100 52 V100 H0Z" fill="#a9d08c" />
          <path d="M0 72 Q30 62 60 70 T100 68 V100 H0Z" fill="#78b356" />
          <path
            d="M10 88 l2 -5 l2 5 M70 92 l2 -6 l2 6 M86 80 l1.6 -4 l1.6 4"
            stroke="#4f8c3a"
            strokeWidth="1.2"
            fill="none"
          />
        </>
      )}
      {habitat === 'forest' && (
        <>
          <path
            d="M-4 66 L8 30 L20 66Z M14 64 L28 22 L42 64Z M66 64 L80 26 L94 64Z M86 66 L98 36 L110 66Z"
            fill="#6d9a72"
          />
          <path d="M0 70 Q40 62 100 70 V100 H0Z" fill="#5f8c48" />
          <path d="M0 84 Q50 78 100 86 V100 H0Z" fill="#4f7a3c" />
        </>
      )}
      {habitat === 'coast' && (
        <>
          <rect y="56" width="100" height="20" fill="#3f93c0" />
          <path
            d="M8 62 h14 M40 66 h18 M70 60 h16 M20 71 h12 M62 72 h20"
            stroke="#8fd0ef"
            strokeWidth="1.2"
          />
          <path d="M0 76 Q50 70 100 77 V100 H0Z" fill="#e6d3a0" />
          <path d="M0 90 Q50 84 100 92 V100 H0Z" fill="#d5bd84" />
        </>
      )}
      {habitat === 'snow' && (
        <>
          <path d="M-6 64 L22 30 L44 58 L64 36 L106 66Z" fill="#dbe6f0" />
          <path d="M22 30 L30 40 L24 38 L18 42Z M64 36 L72 45 L66 43 L58 46Z" fill="#ffffff" />
          <path d="M0 68 Q50 60 100 68 V100 H0Z" fill="#f5f9fc" />
          <path d="M0 86 Q40 80 100 88" stroke="#d3e0ec" strokeWidth="2" fill="none" />
        </>
      )}
      {habitat === 'desert' && (
        <>
          <circle cx="78" cy="22" r="8" fill="#fff4d8" />
          <path d="M0 64 Q30 52 60 62 T100 58 V100 H0Z" fill="#e6b872" />
          <path d="M0 76 Q40 66 100 78 V100 H0Z" fill="#d59c55" />
        </>
      )}
      {habitat === 'cave' && (
        <>
          <path
            d="M0 0 H100 V18 Q86 30 74 20 Q60 34 46 20 Q32 30 20 18 Q10 26 0 16Z"
            fill="#1a1820"
          />
          <path d="M0 70 Q50 60 100 70 V100 H0Z" fill="#5a5262" />
          <path d="M0 84 Q50 78 100 86 V100 H0Z" fill="#4a4352" />
          <path
            d="M14 40 l2 3 l-2 3 l-2 -3Z M86 46 l1.6 2.4 l-1.6 2.4 l-1.6 -2.4Z"
            fill="#6fd8e8"
          />
        </>
      )}
      {habitat === 'ruins' && (
        <>
          <rect x="6" y="20" width="12" height="54" fill="#b8b0a2" />
          <rect x="4" y="16" width="16" height="6" fill="#a39b8c" />
          <rect x="82" y="30" width="12" height="44" fill="#b8b0a2" />
          <rect x="80" y="26" width="16" height="6" fill="#a39b8c" />
          <path d="M0 72 H100 V100 H0Z" fill="#9d9587" />
          <path
            d="M0 82 H100 M24 72 L18 100 M54 72 L54 100 M84 72 L90 100"
            stroke="#857d70"
            strokeWidth="1"
          />
        </>
      )}
    </g>
  );
}

// ---- Subjects (in a 100-unit box, standing on y ≈ 84) ----

function subject(shape: Shape, [a, b, c]: Palette): ReactNode {
  switch (shape) {
    case 'fox':
      return (
        <g {...line}>
          <path d="M33 60 C16 62 8 50 12 40 C18 50 26 52 36 53Z" fill={a} />
          <path d="M12 40 C9 44 9 49 12 52 C14 48 15 44 12 40Z" fill={c} />
          <path d="M36 64 h5 l-0.6 15 h-4.6Z M58 64 h5 l0.4 15 h-4.8Z" fill={b} />
          <path d="M43 66 h4.6 l-0.6 13 h-4Z M52 66 h4.6 l0.2 13 h-4.4Z" fill={a} />
          <path d="M42 77 h5 v3 h-5Z M52 77 h5 v3 h-5Z" fill={b} />
          <path d="M30 58 C30 50 44 48 56 50 C66 51 70 56 68 62 C64 68 40 69 30 58Z" fill={a} />
          <path d="M60 54 L64 36 L70 46 L76 36 L79 50 L90 57 L76 64 L64 62Z" fill={a} />
          <path d="M79 55 L90 57 L76 63 L70 60Z" fill={c} />
          <path d="M66 41 L68 46 M74 41 L74 46" stroke={b} strokeWidth="1.6" />
          <circle cx="75" cy="51" r="1.5" fill={INK} stroke="none" />
          <circle cx="90" cy="57" r="1.2" fill={INK} stroke="none" />
        </g>
      );
    case 'deer':
      return (
        <g {...line}>
          <path d="M34 64 v20 M39 66 v18 M58 66 v18 M63 64 v20" stroke={b} strokeWidth="2.8" />
          <ellipse cx="48" cy="58" rx="20" ry="10" fill={a} />
          <path d="M60 54 L68 36 L75 38 L70 58Z" fill={a} />
          <path d="M66 32 L82 36 L80 42 L68 40Z" fill={a} />
          <path
            d="M71 33 L66 20 M68 26 L61 22 M74 33 L80 19 M78 26 L85 23"
            fill="none"
            stroke={b}
            strokeWidth="2.2"
          />
          <circle cx="40" cy="54" r="1.6" fill={c} stroke="none" />
          <circle cx="47" cy="52" r="1.6" fill={c} stroke="none" />
          <circle cx="54" cy="55" r="1.6" fill={c} stroke="none" />
          <circle cx="74" cy="37" r="1.2" fill={INK} stroke="none" />
          <path d="M28 54 l-4 -4" stroke={b} strokeWidth="2.4" />
        </g>
      );
    case 'boar':
      return (
        <g {...line}>
          <path d="M34 70 v14 M42 72 v12 M60 72 v12 M66 70 v14" stroke={b} strokeWidth="4.4" />
          <ellipse cx="50" cy="62" rx="24" ry="14" fill={a} />
          <path d="M30 52 Q46 40 66 50 Q50 46 30 56Z" fill={b} />
          <path d="M68 52 L88 60 L86 70 L70 72Z" fill={a} />
          <rect x="84" y="60" width="6" height="9" rx="2" fill={b} />
          <path d="M82 70 Q88 66 86 60" fill="none" stroke={c} strokeWidth="2.4" />
          <path d="M70 52 L72 44 L77 52Z" fill={b} />
          <circle cx="78" cy="58" r="1.4" fill={INK} stroke="none" />
          <path d="M26 58 q-5 2 -4 6" fill="none" />
        </g>
      );
    case 'goat':
      return (
        <g {...line}>
          <path d="M33 62 h5 l-0.4 20 h-4Z M57 62 h5 l0.4 20 h-4.6Z" fill={b} />
          <path d="M40 64 h4.6 l-0.2 18 h-4Z M50 64 h4.6 l0.2 18 h-4.4Z" fill={a} />
          <path d="M28 56 C28 46 40 44 52 45 C62 46 66 52 64 60 C60 68 34 68 28 56Z" fill={a} />
          <path d="M36 50 q4 -3 8 0 q4 -3 8 0" fill="none" stroke={b} strokeWidth="1" />
          <path d="M58 50 Q62 40 70 40 L78 48 Q78 54 72 55 L64 58Z" fill={a} />
          <path d="M66 41 Q60 28 72 27 Q66 32 71 41Z" fill={b} />
          <path d="M64 45 L56 43 L62 49Z" fill={a} />
          <path d="M74 54 L74 62 L70 56Z" fill={b} />
          <circle cx="70" cy="46" r="1.4" fill={INK} stroke="none" />
          <path d="M28 52 q-4 -2 -3 -6" fill="none" />
        </g>
      );
    case 'hare':
      return (
        <g {...line}>
          <ellipse cx="44" cy="68" rx="18" ry="12" fill={a} />
          <circle cx="36" cy="70" r="10" fill={a} />
          <circle cx="26" cy="64" r="4" fill={c} />
          <path d="M56 80 v4 M60 78 v6" stroke={b} strokeWidth="3" />
          <path d="M60 52 Q57 28 63 26 Q68 30 65 52Z" fill={a} />
          <path d="M66 52 Q68 30 74 30 Q76 36 70 54Z" fill={a} />
          <circle cx="64" cy="58" r="9" fill={a} />
          <circle cx="68" cy="56" r="1.4" fill={INK} stroke="none" />
          <path d="M72 60 l2 1" />
        </g>
      );
    case 'songbird':
      return (
        <g {...line}>
          <path d="M10 76 Q50 70 92 78" fill="none" stroke="#6b4a2c" strokeWidth="3.2" />
          <path d="M46 66 v8 M54 66 v8" stroke={b} strokeWidth="1.6" />
          <path d="M36 58 L18 66 L22 58 L16 52Z" fill={b} />
          <ellipse cx="48" cy="56" rx="16" ry="12" fill={a} />
          <ellipse cx="52" cy="60" rx="10" ry="7" fill={c} />
          <path d="M36 52 Q48 46 56 56 Q46 58 36 52Z" fill={b} />
          <circle cx="62" cy="44" r="9" fill={a} />
          <path d="M70 42 L80 45 L70 48Z" fill="#f2b33d" />
          <circle cx="64" cy="42" r="1.4" fill={INK} stroke="none" />
        </g>
      );
    case 'heron':
      return (
        <g {...line}>
          <path d="M46 60 L44 88 M52 60 L54 88" stroke={b} strokeWidth="1.8" />
          <ellipse cx="46" cy="54" rx="16" ry="9" fill={a} transform="rotate(-14 46 54)" />
          <path d="M30 58 L22 64 L32 62Z" fill={b} />
          <path d="M56 48 C66 40 56 34 62 24" fill="none" stroke={a} strokeWidth="5" />
          <ellipse cx="64" cy="22" rx="6" ry="4.4" fill={a} />
          <path d="M68 20 L86 24 L68 25Z" fill={c} />
          <path d="M60 19 L50 16" stroke={b} strokeWidth="1.4" />
          <circle cx="65" cy="21" r="1.1" fill={INK} stroke="none" />
        </g>
      );
    case 'fish':
      return (
        <g {...line}>
          <path d="M72 52 L88 40 L85 52 L88 64Z" fill={b} />
          <path d="M16 52 C30 34 60 34 74 52 C60 70 30 70 16 52Z" fill={a} />
          <path d="M40 38 L50 30 L56 40Z" fill={b} />
          <path d="M30 50 C40 44 56 44 66 50" fill="none" stroke={c} strokeWidth="2.6" />
          <path d="M44 62 L50 70 L54 62Z" fill={b} />
          <circle cx="26" cy="50" r="2" fill="#fff" />
          <circle cx="26" cy="50" r="1" fill={INK} stroke="none" />
        </g>
      );
    case 'crab':
      return (
        <g {...line}>
          <path
            d="M34 66 L22 76 L18 84 M38 70 L30 82 M62 70 L70 82 M66 66 L78 76 L82 84"
            fill="none"
            stroke={b}
            strokeWidth="2.4"
          />
          <path d="M34 58 L22 46 M66 58 L78 46" stroke={a} strokeWidth="3.4" />
          <path d="M14 42 Q18 30 28 38 L22 42 L28 46 Q18 54 14 42Z" fill={a} />
          <path d="M86 42 Q82 30 72 38 L78 42 L72 46 Q82 54 86 42Z" fill={a} />
          <ellipse cx="50" cy="62" rx="20" ry="12" fill={a} />
          <path d="M44 52 v-8 M56 52 v-8" stroke={b} strokeWidth="1.6" />
          <circle cx="44" cy="42" r="2.4" fill={c} />
          <circle cx="56" cy="42" r="2.4" fill={c} />
          <path d="M40 64 Q50 68 60 64" fill="none" stroke={b} />
        </g>
      );
    case 'frog':
      return (
        <g {...line}>
          <path d="M22 80 Q24 62 38 66 L40 80Z" fill={b} />
          <path d="M78 80 Q76 62 62 66 L60 80Z" fill={b} />
          <ellipse cx="50" cy="66" rx="22" ry="15" fill={a} />
          <ellipse cx="50" cy="72" rx="14" ry="8" fill={c} />
          <circle cx="38" cy="50" r="7" fill={a} />
          <circle cx="62" cy="50" r="7" fill={a} />
          <circle cx="38" cy="49" r="3.2" fill="#fff" />
          <circle cx="62" cy="49" r="3.2" fill="#fff" />
          <circle cx="38" cy="49" r="1.6" fill={INK} stroke="none" />
          <circle cx="62" cy="49" r="1.6" fill={INK} stroke="none" />
          <path d="M40 62 Q50 66 60 62" fill="none" />
          <path d="M34 80 h-8 M66 80 h8" stroke={b} strokeWidth="3" />
        </g>
      );
    case 'beetle':
      return (
        <g {...line}>
          <path
            d="M36 46 L24 40 M36 58 L22 58 M38 70 L26 78 M64 46 L76 40 M64 58 L78 58 M62 70 L74 78"
            fill="none"
            stroke={INK}
            strokeWidth="2"
          />
          <ellipse cx="50" cy="60" rx="15" ry="19" fill={a} />
          <path d="M50 42 V79" />
          <circle cx="50" cy="38" r="8" fill={b} />
          <path d="M50 30 Q48 20 54 16" fill="none" stroke={b} strokeWidth="3" />
          <ellipse cx="44" cy="54" rx="3" ry="6" fill={c} opacity="0.7" stroke="none" />
        </g>
      );
    case 'butterfly':
      return (
        <g {...line}>
          <path d="M48 46 C34 22 12 26 18 44 C22 54 36 54 48 50Z" fill={a} />
          <path d="M52 46 C66 22 88 26 82 44 C78 54 64 54 52 50Z" fill={a} />
          <path d="M48 52 C34 56 26 70 34 76 C42 80 48 66 48 56Z" fill={b} />
          <path d="M52 52 C66 56 74 70 66 76 C58 80 52 66 52 56Z" fill={b} />
          <circle cx="28" cy="40" r="3.4" fill={c} />
          <circle cx="72" cy="40" r="3.4" fill={c} />
          <circle cx="38" cy="66" r="2.2" fill={c} />
          <circle cx="62" cy="66" r="2.2" fill={c} />
          <ellipse cx="50" cy="54" rx="3" ry="14" fill={INK} />
          <path d="M49 41 Q44 30 40 28 M51 41 Q56 30 60 28" fill="none" />
        </g>
      );
    case 'blob':
      return (
        <g {...line}>
          <path d="M22 82 C18 56 34 40 50 40 C66 40 82 56 78 82Z" fill={a} />
          <path d="M30 56 Q36 46 46 46" fill="none" stroke={c} strokeWidth="3" opacity="0.8" />
          <ellipse cx="42" cy="62" rx="5" ry="6.5" fill="#fff" />
          <ellipse cx="60" cy="62" rx="5" ry="6.5" fill="#fff" />
          <circle cx="43" cy="64" r="2.6" fill={INK} stroke="none" />
          <circle cx="61" cy="64" r="2.6" fill={INK} stroke="none" />
          <path d="M44 74 Q51 79 58 74" fill="none" stroke={b} strokeWidth="2" />
        </g>
      );
    case 'bat':
      return (
        <g {...line}>
          <path
            d="M44 50 C34 36 20 34 8 40 C14 44 14 50 12 56 C18 52 24 54 26 60 C30 54 38 56 44 58Z"
            fill={b}
          />
          <path
            d="M56 50 C66 36 80 34 92 40 C86 44 86 50 88 56 C82 52 76 54 74 60 C70 54 62 56 56 58Z"
            fill={b}
          />
          <ellipse cx="50" cy="56" rx="10" ry="12" fill={a} />
          <path d="M42 46 L42 34 L48 44Z M58 46 L58 34 L52 44Z" fill={a} />
          <circle cx="46" cy="52" r="2" fill={c} stroke="none" />
          <circle cx="54" cy="52" r="2" fill={c} stroke="none" />
          <path d="M46 60 l2 3 l2 -3 l2 3 l2 -3" fill="none" />
        </g>
      );
    case 'brute':
      return (
        <g {...line}>
          <path d="M40 76 v10 M58 76 v10" stroke={b} strokeWidth="6" />
          <path d="M30 76 C28 52 36 40 50 40 C64 40 72 52 70 76Z" fill={a} />
          <path d="M34 72 Q50 78 66 72 L66 80 Q50 84 34 80Z" fill={b} />
          <circle cx="50" cy="38" r="12" fill={a} />
          <path d="M40 32 Q32 22 36 16 Q38 26 44 28Z M60 32 Q68 22 64 16 Q62 26 56 28Z" fill={c} />
          <circle cx="45" cy="37" r="1.8" fill="#ffe58a" stroke="none" />
          <circle cx="55" cy="37" r="1.8" fill="#ffe58a" stroke="none" />
          <path d="M44 44 L46 47 L48 44 M52 44 L54 47 L56 44" fill="#fff" />
          <path d="M72 56 L84 30" stroke="#7a5230" strokeWidth="4.4" />
          <ellipse cx="84" cy="28" rx="5" ry="7" fill="#8a6038" transform="rotate(24 84 28)" />
          <path d="M30 56 L22 66" stroke={a} strokeWidth="6" />
          <path d="M70 56 L74 60" stroke={a} strokeWidth="6" />
        </g>
      );
    case 'golem':
      return (
        <g {...line}>
          <rect x="34" y="70" width="12" height="14" rx="2" fill={b} />
          <rect x="54" y="70" width="12" height="14" rx="2" fill={b} />
          <rect x="28" y="40" width="44" height="34" rx="5" fill={a} />
          <rect x="18" y="44" width="12" height="26" rx="4" fill={b} />
          <rect x="70" y="44" width="12" height="26" rx="4" fill={b} />
          <rect x="38" y="22" width="24" height="18" rx="4" fill={a} />
          <rect x="44" y="28" width="12" height="4" rx="2" fill={c} />
          <circle cx="50" cy="56" r="6" fill={c} />
          <circle cx="50" cy="56" r="2.6" fill="#fff" stroke="none" />
          <path d="M28 50 h8 M64 64 h8 M40 74 v-4" fill="none" />
        </g>
      );
    case 'mushroom':
      return (
        <g {...line}>
          <path d="M40 58 Q38 74 36 84 H64 Q62 74 60 58Z" fill="#f4ecdc" />
          <path d="M18 60 C18 34 34 24 50 24 C66 24 82 34 82 60Z" fill={a} />
          <circle cx="36" cy="42" r="4.4" fill={c} />
          <circle cx="56" cy="36" r="3.4" fill={c} />
          <circle cx="68" cy="50" r="3.8" fill={c} />
          <circle cx="44" cy="54" r="2.6" fill={c} />
          <path d="M30 84 q6 -4 12 0 M58 84 q6 -4 12 0" fill="none" stroke="#4f7a3c" />
        </g>
      );
    case 'fruit':
      return (
        <g {...line}>
          <path
            d="M50 34 C38 26 22 30 22 52 C22 70 36 82 50 78 C64 82 78 70 78 52 C78 30 62 26 50 34Z"
            fill={a}
          />
          <path d="M30 46 Q32 38 40 36" fill="none" stroke={c} strokeWidth="3" opacity="0.8" />
          <path d="M50 34 Q50 24 54 18" fill="none" stroke="#6b4a2c" strokeWidth="2.6" />
          <path d="M54 24 Q64 14 74 20 Q64 30 54 24Z" fill="#5fae4a" />
        </g>
      );
    case 'herb':
      return (
        <g {...line}>
          <path d="M50 86 V28" fill="none" stroke={b} strokeWidth="2.4" />
          <path d="M50 72 Q30 70 24 56 Q40 54 50 66Z" fill={a} />
          <path d="M50 64 Q70 62 78 48 Q60 46 50 58Z" fill={a} />
          <path d="M50 50 Q32 46 30 32 Q44 32 50 44Z" fill={a} />
          <path d="M50 42 Q66 38 70 24 Q56 24 50 36Z" fill={a} />
          <path d="M50 30 Q44 20 50 12 Q56 20 50 30Z" fill={c} />
        </g>
      );
    case 'crystal':
      return (
        <g {...line}>
          <path d="M44 84 L36 44 L46 24 L56 44 L52 84Z" fill={a} />
          <path d="M46 24 L46 84" fill="none" stroke={b} strokeWidth="1" />
          <path d="M60 84 L58 58 L68 44 L76 58 L70 84Z" fill={a} />
          <path d="M28 84 L24 64 L32 54 L38 66 L36 84Z" fill={b} />
          <path d="M40 46 L46 30" fill="none" stroke="#fff" strokeWidth="2" opacity="0.8" />
          <path d="M20 84 H82" stroke="#5a5262" strokeWidth="2" />
        </g>
      );
    case 'egg':
      return (
        <g {...line}>
          <path d="M24 82 Q50 70 76 82 Q50 92 24 82Z" fill="#b08a54" />
          <path
            d="M50 22 C34 22 28 48 28 60 C28 74 38 82 50 82 C62 82 72 74 72 60 C72 48 66 22 50 22Z"
            fill={a}
          />
          <circle cx="42" cy="44" r="2.4" fill={b} stroke="none" />
          <circle cx="58" cy="52" r="2" fill={b} stroke="none" />
          <circle cx="46" cy="64" r="1.8" fill={b} stroke="none" />
          <circle cx="60" cy="70" r="2.2" fill={b} stroke="none" />
          <path d="M38 40 Q40 32 46 30" fill="none" stroke="#fff" strokeWidth="2.6" opacity="0.8" />
        </g>
      );
    case 'sword':
      return (
        <g {...line} transform="rotate(-40 50 50)">
          <path d="M46 70 V18 L50 8 L54 18 V70Z" fill={a} />
          <path d="M50 12 V68" fill="none" stroke={b} strokeWidth="1" />
          <rect x="34" y="68" width="32" height="6" rx="2" fill={c} />
          <rect x="46" y="74" width="8" height="16" rx="2" fill="#6b4a2c" />
          <circle cx="50" cy="93" r="4" fill={c} />
        </g>
      );
    case 'shield':
      return (
        <g {...line}>
          <path d="M50 16 L80 26 C80 56 70 74 50 86 C30 74 20 56 20 26Z" fill={a} />
          <path
            d="M50 22 L74 30 C74 54 66 68 50 78 C34 68 26 54 26 30Z"
            fill="none"
            stroke={b}
            strokeWidth="2"
          />
          <path d="M50 36 L60 50 L50 64 L40 50Z" fill={c} />
          <circle cx="30" cy="32" r="1.6" fill={b} stroke="none" />
          <circle cx="70" cy="32" r="1.6" fill={b} stroke="none" />
        </g>
      );
    case 'bow':
      return (
        <g {...line}>
          <path d="M30 14 C76 26 76 74 30 86" fill="none" stroke={a} strokeWidth="5" />
          <path d="M30 14 C76 26 76 74 30 86" fill="none" stroke={b} strokeWidth="1" />
          <path d="M30 14 V86" stroke="#efe6d2" strokeWidth="1" />
          <rect x="60" y="44" width="8" height="12" rx="2" fill={c} />
          <path d="M18 50 H84" stroke="#6b4a2c" strokeWidth="2" />
          <path d="M84 46 L92 50 L84 54Z M18 46 L24 50 L18 54" fill={c} />
        </g>
      );
    case 'axe':
      return (
        <g {...line} transform="rotate(-18 50 50)">
          <rect x="46" y="20" width="7" height="68" rx="3" fill="#8a6038" />
          <path d="M53 26 C70 18 82 26 80 40 C82 54 70 60 53 52Z" fill={a} />
          <path
            d="M74 26 C80 32 80 48 74 54"
            fill="none"
            stroke="#fff"
            strokeWidth="2"
            opacity="0.8"
          />
          <path d="M46 30 L36 26 L38 40 L46 38Z" fill={b} />
          <rect x="45" y="80" width="9" height="6" rx="2" fill={c} />
        </g>
      );
    case 'chest':
      return (
        <g {...line}>
          <rect x="20" y="50" width="60" height="32" rx="3" fill={a} />
          <path d="M20 50 C20 30 80 30 80 50Z" fill={a} />
          <path d="M20 50 H80" />
          <path d="M30 36 V82 M70 36 V82" stroke={b} strokeWidth="4" />
          <path d="M20 66 H80" stroke={b} strokeWidth="2" />
          <rect x="44" y="46" width="12" height="14" rx="2" fill={c} />
          <circle cx="50" cy="52" r="2" fill={INK} stroke="none" />
          <path d="M26 30 l2 -6 l2 6 M74 28 l2 -6 l2 6" stroke="#ffe58a" fill="none" />
        </g>
      );
    case 'amulet':
      return (
        <g {...line}>
          <path
            d="M28 18 Q50 52 72 18"
            fill="none"
            stroke="#c9ced6"
            strokeWidth="2.2"
            strokeDasharray="3 2.4"
          />
          <circle cx="50" cy="60" r="20" fill="#d6dbe2" />
          <circle cx="50" cy="60" r="14" fill={a} />
          <path d="M50 48 L58 60 L50 72 L42 60Z" fill={c} />
          <path d="M42 52 Q46 48 50 48" fill="none" stroke="#fff" strokeWidth="2" opacity="0.8" />
          <rect x="46" y="36" width="8" height="6" rx="2" fill="#c9ced6" />
        </g>
      );
  }
}

/** An entry's picture: its setting and its subject (or the subject alone, as a silhouette). */
export function EntryArt({
  shape,
  palette,
  habitat,
  id,
  silhouette = false,
  className,
}: {
  shape: Shape;
  palette: Palette;
  habitat: Habitat;
  id: string;
  silhouette?: boolean;
  className?: string;
}) {
  return (
    <svg
      viewBox="0 0 100 100"
      className={className}
      aria-hidden="true"
      preserveAspectRatio="xMidYMid slice"
    >
      {!silhouette && <Scene habitat={habitat} id={id} />}
      <g data-silhouette={silhouette || undefined}>{subject(shape, palette)}</g>
    </svg>
  );
}

// ---- The empty frame's lens: a six-bladed aperture, the mark of a photo not yet taken ----

export function Lens({ className }: { className?: string }) {
  const blades = Array.from({ length: 6 }, (_, i) => i * 60);
  return (
    <svg viewBox="0 0 40 40" className={className} aria-hidden="true">
      <circle cx="20" cy="20" r="15" fill="none" stroke="currentColor" strokeWidth="2.4" />
      {blades.map((angle) => (
        <path
          key={angle}
          d="M20 7.6 L27.6 14.2"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          transform={`rotate(${angle} 20 20)`}
        />
      ))}
      <circle cx="20" cy="20" r="4" fill="currentColor" />
    </svg>
  );
}

// ---- Tab icons (24-unit box, one colour) ----

export function TabIcon({ category, className }: { category: Category; className?: string }) {
  return (
    <svg viewBox="0 0 24 24" className={className} aria-hidden="true" fill="currentColor">
      {category === 'creatures' && (
        <>
          <ellipse cx="12" cy="16.6" rx="5.6" ry="4.8" />
          <ellipse cx="5" cy="10.4" rx="2.4" ry="3" transform="rotate(-20 5 10.4)" />
          <ellipse cx="9.4" cy="5.8" rx="2.4" ry="3.1" transform="rotate(-6 9.4 5.8)" />
          <ellipse cx="14.6" cy="5.8" rx="2.4" ry="3.1" transform="rotate(6 14.6 5.8)" />
          <ellipse cx="19" cy="10.4" rx="2.4" ry="3" transform="rotate(20 19 10.4)" />
        </>
      )}
      {category === 'monsters' && (
        <path
          fillRule="evenodd"
          d="M3 2.4 L7.6 7 Q12 5 16.4 7 L21 2.4 L20 10 Q21 13 20 16 L16 22 H8 L4 16 Q3 13 4 10Z M7.4 11.6 L11 13 L10.4 15.2 L7.6 14.2Z M16.6 11.6 L13 13 L13.6 15.2 L16.4 14.2Z M9.4 18 L10.6 19.8 L12 18 L13.4 19.8 L14.6 18Z"
        />
      )}
      {category === 'materials' && (
        <path
          fillRule="evenodd"
          d="M2.4 13 C2.4 6 7 2.4 12 2.4 S21.6 6 21.6 13 Z M9 14.4 H15 L15.8 21.6 H8.2Z M7.6 7 a1.6 1.6 0 1 0 0.01 0Z M14.6 6 a1.2 1.2 0 1 0 0.01 0Z"
        />
      )}
      {category === 'equipment' && (
        <g transform="rotate(45 12 12)">
          <path d="M12 0.6 L14.8 3.6 V14.4 H9.2 V3.6Z" />
          <rect x="6" y="15" width="12" height="2.8" rx="1" />
          <rect x="10.6" y="18.2" width="2.8" height="3.4" />
          <circle cx="12" cy="22.4" r="1.6" />
        </g>
      )}
      {category === 'treasures' && (
        <path
          fillRule="evenodd"
          d="M6 3.4 H18 L22.4 9 L12 21.6 L1.6 9Z M8.4 9.6 L12 18 L10.4 9.6Z M15.6 9.6 L12 18 L13.6 9.6Z M4.8 8.2 H19.2 L17 5.2 H7Z"
        />
      )}
    </svg>
  );
}

// ---- Effect and stat icons (16-unit box) ----

export function EffectGlyph({ icon, className }: { icon: EffectIcon; className?: string }) {
  return (
    <svg viewBox="0 0 16 16" className={className} aria-hidden="true" fill="currentColor">
      {icon === 'attack' && (
        <>
          <path d="M9.6 1 L15 1 L15 6.4 L7 14.4 L1.6 9Z" />
          <path d="M0.6 14 L3.4 11.2 L4.8 12.6 L2 15.4Z" />
        </>
      )}
      {icon === 'defense' && (
        <path d="M8 0.8 L14.4 3 C14.4 9 12 12.8 8 15.2 C4 12.8 1.6 9 1.6 3Z" />
      )}
      {icon === 'speed' && <path d="M9.4 0.8 L3 9 H7.4 L6.4 15.2 L13 6.6 H8.6Z" />}
      {icon === 'heat' && (
        <path d="M8 0.6 C10 4 13.4 6 13.4 10 C13.4 13.4 11 15.4 8 15.4 C5 15.4 2.6 13.4 2.6 10 C2.6 7.6 4 6.2 5 5 C5.4 7 6.4 8 7.4 8 C6.6 5.4 7 3 8 0.6Z" />
      )}
      {icon === 'cold' && (
        <g stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" fill="none">
          <path d="M8 1 V15 M1.9 4.5 L14.1 11.5 M1.9 11.5 L14.1 4.5" />
          <path d="M6 2.6 L8 4.4 L10 2.6 M6 13.4 L8 11.6 L10 13.4" />
        </g>
      )}
      {icon === 'glow' && (
        <>
          <circle cx="8" cy="8" r="3.6" />
          <path
            d="M8 0.6 V3 M8 13 V15.4 M0.6 8 H3 M13 8 H15.4 M2.8 2.8 L4.4 4.4 M11.6 11.6 L13.2 13.2 M2.8 13.2 L4.4 11.6 M11.6 4.4 L13.2 2.8"
            stroke="currentColor"
            strokeWidth="1.4"
            strokeLinecap="round"
          />
        </>
      )}
      {icon === 'stealth' && (
        <path
          d="M1 8 Q8 1.4 15 8 Q8 14.6 1 8Z M5 8 a3 3 0 1 0 6 0 a3 3 0 1 0 -6 0Z M2 14 L14 2"
          fillRule="evenodd"
          stroke="currentColor"
          strokeWidth="1.4"
        />
      )}
    </svg>
  );
}

export function Heart({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 16 16" className={className} aria-hidden="true">
      <path
        d="M8 14.6 C3 11 0.8 8.2 0.8 5.4 C0.8 3 2.6 1.4 4.6 1.4 C6 1.4 7.2 2.2 8 3.4 C8.8 2.2 10 1.4 11.4 1.4 C13.4 1.4 15.2 3 15.2 5.4 C15.2 8.2 13 11 8 14.6Z"
        fill="#e8333f"
      />
      <path
        d="M3.6 4.4 Q4.4 3.4 5.6 3.6"
        stroke="#ff9aa0"
        strokeWidth="1.2"
        fill="none"
        strokeLinecap="round"
      />
    </svg>
  );
}

/** A threat mark (a small horned face) or a value mark (a faceted coin). */
export function StatMark({
  kind,
  ...rest
}: SVGProps<SVGSVGElement> & { kind: 'threat' | 'value' }) {
  return (
    <svg viewBox="0 0 16 16" {...rest} aria-hidden="true" fill="currentColor">
      {kind === 'threat' ? (
        <path
          fillRule="evenodd"
          d="M2 1 L5 4.4 Q8 3.4 11 4.4 L14 1 L13.4 6.6 Q14 9 13 11 L10.6 15 H5.4 L3 11 Q2 9 2.6 6.6Z M5 7.6 L7.2 8.6 L6.8 10 L5.2 9.4Z M11 7.6 L8.8 8.6 L9.2 10 L10.8 9.4Z"
        />
      ) : (
        <path
          fillRule="evenodd"
          d="M8 0.8 L14.4 4.6 V11.4 L8 15.2 L1.6 11.4 V4.6Z M8 4 L11.2 6 V10 L8 12 L4.8 10 V6Z"
        />
      )}
    </svg>
  );
}

// ---- The tablet's script: each letter a stem with hooks, bars, a foot or a dot ----

const GLYPH_W = 5;
const GLYPH_GAP = 1.6;

function glyphPath(code: number, x: number) {
  const bits = (code * 37 + 11) % 61;
  const parts = [`M${x + 2.5} 0.6 V7.4`];
  if (bits & 1) parts.push(`M${x + 2.5} 0.6 H${x + 4.6} V2.4`);
  else parts.push(`M${x + 2.5} 0.6 H${x + 0.4}`);
  if (bits & 2) parts.push(`M${x + 2.5} 4 H${x + 4.6}`);
  if (bits & 4) parts.push(`M${x + 2.5} 4 H${x + 0.4} V5.8`);
  if (bits & 8) parts.push(`M${x + 2.5} 7.4 H${x + 4.6}`);
  else if (bits & 16) parts.push(`M${x + 2.5} 7.4 H${x + 0.4}`);
  if (bits & 32) parts.push(`M${x + 4.4} 5.6 V7.4`);
  return parts.join(' ');
}

/** A name written in the tablet's script: an SVG as wide as the name needs, 8 units tall. */
export function Script({ text, className }: { text: string; className?: string }) {
  let x = 0;
  const paths: string[] = [];
  for (const letter of text.toLowerCase()) {
    if (letter === ' ') {
      x += GLYPH_W * 0.8;
      continue;
    }
    paths.push(glyphPath(letter.charCodeAt(0), x));
    x += GLYPH_W + GLYPH_GAP;
  }
  const width = Math.max(1, x - GLYPH_GAP);
  return (
    <svg
      viewBox={`-0.6 0 ${width + 1.2} 8`}
      className={className}
      style={{ '--ratio': (width + 1.2) / 8 } as CSSProperties}
      aria-hidden="true"
    >
      <path
        d={paths.join(' ')}
        fill="none"
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinecap="square"
      />
    </svg>
  );
}

// ---- Controller glyphs ----

export function FaceGlyph({ letter, className }: { letter: string; className?: string }) {
  return (
    <svg viewBox="0 0 32 32" className={className} aria-hidden="true">
      <circle cx="16" cy="16" r="16" fill="var(--glyph)" />
      <text
        x="16"
        y="22.2"
        textAnchor="middle"
        fontSize="18"
        fill="var(--glyph-ink)"
        style={{ fontWeight: 700, fontStyle: 'normal' }}
      >
        {letter}
      </text>
    </svg>
  );
}

export function KeyGlyph({ letter, className }: { letter: string; className?: string }) {
  return (
    <svg viewBox="0 0 32 28" className={className} aria-hidden="true">
      <rect x="0" y="0" width="32" height="28" rx="6" fill="var(--glyph)" />
      <text
        x="16"
        y="20.6"
        textAnchor="middle"
        fontSize="18"
        fill="var(--glyph-ink)"
        style={{ fontWeight: 700, fontStyle: 'normal' }}
      >
        {letter}
      </text>
    </svg>
  );
}

/** The right stick seen slightly from above, pointing on to the next tab. */
export function StickGlyph({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 46 36" className={className} aria-hidden="true">
      <ellipse cx="17.6" cy="32.4" rx="6" ry="2.6" fill="var(--glyph)" />
      <path d="M12 27 h11.4 l-1 4.6 h-9.4 z" fill="var(--glyph)" />
      <ellipse cx="18" cy="16.6" rx="17.4" ry="12" fill="var(--glyph-shade)" />
      <ellipse cx="18" cy="14" rx="18" ry="12.6" fill="var(--glyph)" />
      <ellipse cx="18" cy="14.4" rx="14.6" ry="10" fill="var(--glyph-ink)" />
      <ellipse
        cx="18"
        cy="14.4"
        rx="13"
        ry="8.6"
        fill="none"
        stroke="var(--glyph)"
        strokeWidth="1.4"
      />
      <text
        x="18"
        y="20"
        textAnchor="middle"
        fontSize="15"
        fill="var(--glyph)"
        style={{ fontWeight: 600, fontStyle: 'normal' }}
      >
        R
      </text>
      <path d="M38.4 8 L45.4 15.4 L38.4 22.8 Z" fill="var(--glyph)" />
    </svg>
  );
}

/** The page arrow: a solid arrowhead with a notched back. */
export function PageArrow({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 18 44" className={className} aria-hidden="true">
      <path d="M1 1 L17 22 L1 43 L6 22Z" fill="currentColor" />
    </svg>
  );
}

/** The sensor in the corner: a mast sending out arcs. */
export function SensorGlyph({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" className={className} aria-hidden="true">
      <circle cx="12" cy="12" r="11.4" fill="var(--sensor-ground)" />
      <g fill="none" stroke="var(--sensor-ink)" strokeWidth="1.6" strokeLinecap="round">
        <path d="M8.4 7 a5 5 0 0 0 0 7 M15.6 7 a5 5 0 0 1 0 7" />
        <path d="M6 4.8 a8.4 8.4 0 0 0 0 11.4 M18 4.8 a8.4 8.4 0 0 1 0 11.4" opacity="0.7" />
      </g>
      <circle cx="12" cy="10.4" r="2" fill="var(--sensor-ink)" />
      <path d="M11 12 H13 L13.8 20 H10.2Z" fill="var(--sensor-ink)" />
    </svg>
  );
}

/** A section title's rule end: a small ring held by a diamond. */
export function RuleEnd(props: SVGProps<SVGSVGElement>) {
  return (
    <svg viewBox="0 0 14 8" {...props} aria-hidden="true">
      <path d="M5.4 4 L8.6 1.6 L11.8 4 L8.6 6.4Z" fill="currentColor" />
      <circle cx="3.2" cy="4" r="2.3" fill="none" stroke="currentColor" strokeWidth="1.3" />
      <path d="M11.8 4 H14" stroke="currentColor" strokeWidth="1.3" />
    </svg>
  );
}

// ---- The bezel ----

/**
 * One corner of the tablet's frame: a carved stone bracket of stepped blocks, a round boss set
 * with a glowing lens at the corner, and a knob at the end of each arm. Drawn for the top left
 * corner; the others are mirrors.
 */
export function BezelCorner({ id, ...rest }: SVGProps<SVGSVGElement> & { id: string }) {
  const stone = `${id}-stone`;
  return (
    <svg viewBox="0 0 160 150" {...rest} aria-hidden="true">
      <defs>
        <linearGradient id={stone} x1="0" y1="0" x2="1" y2="1">
          <stop offset="0" stopColor="#d2b886" />
          <stop offset="0.55" stopColor="#b0915e" />
          <stop offset="1" stopColor="#8a6f45" />
        </linearGradient>
      </defs>
      <g stroke="#4b3b22" strokeWidth="1.6" strokeLinejoin="round">
        {/* The arms: a horizontal and a vertical bar, stepping down twice towards their ends. */}
        <path
          d="M0 0 H150 Q158 0 158 8 Q158 16 150 16 H104 V22 H62 V30 H34 V62 H26 V104 H18 V140 Q18 148 10 148 Q2 148 2 140 Z"
          fill={`url(#${stone})`}
        />
        {/* Carved grooves along the arms. */}
        <path
          d="M40 9 H140 M28 18 V44 M12 50 V132"
          fill="none"
          stroke="#6f5833"
          strokeWidth="2.2"
        />
        <path
          d="M40 10.6 H140 M29.6 18 V44 M13.6 50 V132"
          fill="none"
          stroke="#e6d2a2"
          strokeWidth="0.9"
        />
        {/* Block joints. */}
        <path d="M84 16 V22 M104 0 V16 M18 84 H26 M0 104 H18" fill="none" strokeWidth="1.2" />
        {/* The boss at the corner. */}
        <circle cx="26" cy="26" r="21" fill={`url(#${stone})`} />
        <circle cx="26" cy="26" r="14" fill="#2b2418" />
        <circle cx="26" cy="26" r="10.4" fill="#0d3c74" stroke="#40b8ff" strokeWidth="2" />
        <circle cx="26" cy="26" r="4" fill="#9fe6ff" stroke="none" />
        {/* Knobs at the ends of the arms. */}
        <circle cx="150" cy="8" r="5" fill="#c9ad7a" />
        <circle cx="10" cy="140" r="5" fill="#c9ad7a" />
      </g>
    </svg>
  );
}

/** The carved band along the top and bottom bars: a repeat of long and short blocks and studs. */
export function BezelBand({ id, className }: { id: string; className?: string }) {
  return (
    <svg className={className} aria-hidden="true" preserveAspectRatio="none">
      <defs>
        <pattern id={id} width="56" height="14" patternUnits="userSpaceOnUse">
          <rect
            x="1"
            y="3"
            width="30"
            height="8"
            rx="2"
            fill="#a58a5a"
            stroke="#4b3b22"
            strokeWidth="1"
          />
          <path d="M5 7 H27" stroke="#6f5833" strokeWidth="1.4" />
          <rect
            x="35"
            y="4.6"
            width="10"
            height="4.8"
            rx="1.6"
            fill="#97804f"
            stroke="#4b3b22"
            strokeWidth="1"
          />
          <circle cx="51" cy="7" r="2.4" fill="#b59a68" stroke="#4b3b22" strokeWidth="1" />
        </pattern>
      </defs>
      <rect width="100%" height="100%" fill={`url(#${id})`} />
    </svg>
  );
}
```

### creature-compendium/use-autoplay.ts

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

'use client';

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

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

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

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

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