# Scope view

> A game tablet’s spyglass: a round lens over a painted vector landscape, a reticle, smooth zoom, coloured pins placed in the world and a corner minimap.

The screen a player raises to look far across the world and mark what they find. A round lens, clipped by the tablet’s bars, frames a painted valley at sunset: a smoking peak, table mesas, a river, a pool and a watchtower, with scanlines, a vignette and a refracting rim. Drag or use the arrows to look around, scroll or use the gauge to zoom up to 8× (the art is vector, so it stays sharp), and press A to place a pin of the selected colour under the reticle. Pins stay on their spot as the view moves, show on the minimap at their bearing, and come back when you aim at them and press A again. Minus changes the colour, B lowers the scope like an iris.

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

Category: Games · Screens.

## Anatomy

1. **Title**: The screen’s `title` in the top bar, in cyan with a soft glow; the bars and their bronze bands are drawn in SVG.
2. **Lens**: A circle cut out of the tablet, clipped by the bars: the painted world under one transform, with scanlines, an edge distortion, a vignette and a rim. It is the focusable `role="application"`.
3. **Reticle**: Two arcs, a disc and a dot fixed at the centre of the lens; the arcs squeeze in while zooming and the reticle turns cyan when it rests on a pin.
4. **Pin**: A coloured marker stored in world units, so it stays on its spot as the view moves and zooms; each colour has its own mark.
5. **Zoom gauge**: A vertical `role="slider"` from 1× to `maxZoom`, on a log scale, with the zoom read out beside its thumb.
6. **Pin palette**: One stamp per colour in a `radiogroup`; a caret marks the selected one and a used-up colour dims.
7. **Minimap**: A round map in the corner with a view cone that turns with the view and narrows as it zooms, and a dot per pin.
8. **Button hints**: Edit pins, Quit and Place Pin (Remove Pin on a pin, Raise Scope when lowered), each a button with its controller glyph.

## When to use

- A full-screen tool the player raises to look far across the world, pick out a landmark and come back to the game.
- Marking places to visit later: a few coloured pins dropped under the reticle that stay on their spot and show on the minimap.
- A view the player steers with a stick or a drag and zooms smoothly, where the art has to stay sharp at every zoom.
- A screen played with a controller as much as with a mouse: arrows, Q and E, A to act and B to leave, with button hints at the foot.
- A device moment in a game’s fiction (spyglass, camera, binoculars), framed by the tablet it is shown on.

## When not to use

- The whole map with every region and marker at once. Use a world map, which pans a flat chart.
- Showing where things are around the player while they play. Use a minimap or a radar in the HUD corner.
- Zooming into a photo or a product image on a web page. Use an image zoom or a lightbox.
- Aiming a weapon or a throw during play. Use an aim reticle over the game view; this one pauses play to look.
- A list of saved places to pick from. Use a list or a menu; pins here are placed by looking, not by name.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/scope-view.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 { ScopeView, type ScopePin } from './scope-view';

export function Spyglass({ onClose }: { onClose: () => void }) {
  return (
    <ScopeView
      autoplay={false}
      defaultLook={{ x: 1442, y: 660 }}
      defaultZoom={2}
      defaultPins={[]}
      onPinsChange={(pins: ScopePin[]) => console.log(pins.length, 'pins')}
      onQuit={onClose}
    />
  );
}
```

## States

- **Raised**: The default: the lens open at 1× with the blue pin in view and the next free colour selected.
- **Zoomed in**: `defaultZoom={3.2}` on the watchtower: the vector art is redrawn sharp and the minimap’s cone narrows.
- **On a pin**: The reticle rests on the blue pin: it turns cyan and the A hint becomes Remove Pin.
- **No pins left**: Every colour placed (`pinLimit={1}`): the stamps dim, the caret hides and Place Pin is disabled.
- **No pins**: `defaultPins={[]}`: the minimap shows only the view cone.
- **Lowered**: `defaultOpen={false}`: the lens is shut like an iris and the only hint is Raise Scope.
- **No minimap**: `showMinimap={false}`: the corner is left to the tablet.
- **Custom palette**: Three colours with `pinLimit={2}` and `title="Spyglass"`: each colour can stand twice.
- **Narrow**: Under 640 px the screen takes its 480 × 600 layout: a smaller lens, the gauge on the left edge.

## How it works

- The tablet is drawn in SVG in its own units, 1024 × 576 (480 × 600 when the stage is under 640 px, chosen with a ResizeObserver): a navy surface with faint range rings, two black bars with stepped bronze bands, four stepped bronze brackets with a cyan gem in each boss, and the blue light the bars throw on the screen. Every HTML part is placed in the same units times --u, 1 px at full size, so the whole screen scales as one piece.
- The world is 2400 × 1000 units of vector art, generated once at load from a seeded random sequence (midpoint displacement for the ridges, scattered crowns for the woods), so server and browser draw the same picture. The lens shows it through one group transform, translate then scale: zooming rescales vector paths instead of enlarging pixels, so the art is sharp at 8×.
- Over the world, in screen space: a copy of it enlarged 4.5 % and masked to the outer ring of the lens (the edge distortion), vertical scanlines that shimmer slightly, a teal tint, a vignette and a light rim. All of it sits in a circle clip; the lens element opens and shuts with a clip-path circle transition, like an iris.
- The view (centre and zoom) eases to a target in a requestAnimationFrame loop that runs only while it moves: input sets the target with a snappy rate, the demo with a slow one, and zoom eases in log space so each doubling takes the same time. While zooming, the reticle’s arcs squeeze in and spring back.
- Pins are stored in world units. Each is drawn at (world − centre) × scale, so it stays on its spot at any zoom; a new one drops in with a squash and a ripple leaves the reticle, a removed one lifts away. Aiming within a few units of a pin turns the reticle cyan and the A hint into Remove Pin.
- The minimap draws a view cone at the bearing of the view’s centre (the world spans 150° of compass) that narrows as the zoom grows, and each pin as a dot at its bearing and a distance read from its height above the horizon.
- A demo raises the scope, swings to the tower, zooms, pins it, swings to the peak, changes colour, pins it, takes the first pin back and lowers the scope, while it is on screen and untouched; any input hands it to the reader.

## API reference

- **title** (`string`, default `'Scope'`): The screen’s title, in the top bar.
- **colors** (`ScopePinColor[]`, default `Blue, Red, Yellow, Green, Pink`): The pin colours of the palette, in order, as { id, label, color }. Each stamp gets its own mark, so colour is never the only cue.
- **defaultPins** (`ScopePin[]`, default `a blue and a red pin`): The pins already placed, as { id, color, x, y } in world units (0–2400 across, 0–1000 down).
- **pinLimit** (`number`, default `1`): How many pins of each colour can stand at once; a used-up colour dims.
- **defaultLook** (`{ x: number; y: number }`, default `{ x: 1200, y: 470 }`): Where the reticle points at first, in world units. Home returns there.
- **defaultZoom** (`number`, default `1`): The zoom it opens at, from 1 to maxZoom.
- **maxZoom** (`number`, default `8`): The strongest zoom, the top of the gauge.
- **defaultOpen** (`boolean`, default `true`): Whether the lens is raised at first.
- **showMinimap** (`boolean`, default `true`): Shows the corner minimap with the view cone and the pins.
- **onPinsChange** (`(pins: ScopePin[]) => void`): Called with every pin after one is placed or removed.
- **onZoomChange** (`(zoom: number) => void`): Called when the zoom target changes.
- **onQuit** (`() => void`): Called when the lens is lowered (B, Escape or the Quit hint).
- **onRaise** (`() => void`): Called when the lens is raised again (A or the Raise Scope hint).
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a short look-around: aim, zoom, pin, change colour, take a pin back, lower and raise.
- **colorScheme** (`'light' | 'dark' | 'system'`): Forces light or dark for the screen’s edge and shadow on the page; by default it follows the page. The screen itself stays dark.
- **className** (`string`): Added to the root.

## Accessibility

- The lens is a focusable application region named with its keys: arrows look around (Shift for bigger steps), Q and E (or Page Up, Page Down) zoom, A, Enter or Space place or remove a pin, minus or P change the colour, B or Escape lower the scope, Home resets the view.
- The zoom gauge is a vertical slider with aria-valuetext (“2.4 times”), arrows, Page Up, Page Down, Home and End, and drag. The palette is a radio group of buttons named by colour and state (“Blue, all placed”), with arrow keys between the colours that have pins left.
- A polite live region, which also describes the lens, says what happened and where: “Yellow pin placed on the watchtower. 2 left.”, “Pink pin selected.”, “No pins left…”, “Scope lowered.”
- The hints are buttons with aria-keyshortcuts; the landscape, reticle, bezel and minimap are aria-hidden.
- Every control shows a :focus-visible ring drawn inside the screen, so the bars never hide it; for the lens it is a ring just inside its edge.
- Reduced motion: the view jumps instead of easing, the iris, pins and caret change at once, nothing ripples, shakes or shimmers, and the demo never plays.

## Source

### scope-view/scope-view.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import {
  Backdrop,
  Bezel,
  FaceGlyph,
  LAYOUTS,
  MiniGround,
  PinMarker,
  PinStamp,
  type Layout,
} from './art';
import { Landscape, WORLD, landmarkAt } from './landscape';
import { useAutoplay } from './use-autoplay';
import styles from './scope-view.module.css';

/*
 * Scope view: the spyglass screen of a game's tablet. The player raises it to look far across
 * the world, picks out a landmark under the reticle and marks it with a coloured pin, which also
 * shows on the corner minimap.
 *
 * - The tablet is drawn in SVG in its own units (1024 × 576, or 480 × 600 on a narrow stage):
 *   the navy surface, the black bars with their bronze bands, stepped bronze corners and the blue
 *   light they throw. Every size is a multiple of --u, 1 px at full size, so the whole screen
 *   scales as one piece and stays sharp.
 * - The lens is a circle cut out of that surface, clipped by the bars. Inside it, the painted
 *   world (landscape.tsx, 2400 × 1000 world units of vector art) is one group under a single
 *   transform: zooming scales the group, so the art is redrawn sharp at any zoom, never blown up.
 *   Over it, in screen space: an enlarged copy masked to the outer ring (the edge distortion),
 *   vertical scanlines, a vignette and a rim.
 * - The view (centre x, centre y, zoom) eases to a target in one requestAnimationFrame loop that
 *   runs only while it moves: drag, arrows and taps set the target, and the view follows with a
 *   short lag; wheel, the gauge, Q and E zoom at the reticle, from 1× to maxZoom.
 * - A (or Enter, Space, the hint) places a pin of the selected colour where the reticle is, in
 *   world units, so it stays on its spot as the view moves; aimed at a pin, A removes it. Minus
 *   (or P) moves the selection to the next colour that has pins left; when none are left the
 *   palette shakes. B (or Escape) closes the lens like an iris and calls onQuit; A raises it.
 * - The minimap turns its view cone with the yaw and narrows it with the zoom, and shows each
 *   pin at its bearing and distance.
 * - A demo plays a short look-around while the scope is on screen and untouched: aim at the
 *   tower, zoom, pin it, swing to the peak, change colour, pin it, take the first pin back, close
 *   and raise again. Any input hands it to the reader.
 */

export type ScopePinColor = {
  /** Used in pins' `color`. */
  id: string;
  /** Said by screen readers: "Yellow pin placed on the watchtower". */
  label: string;
  /** The pin's colour, any CSS colour. */
  color: string;
};

export type ScopePin = {
  id: string;
  /** The id of its colour. */
  color: string;
  /** Where it stands, in world units (0–2400 across, 0–1000 down). */
  x: number;
  y: number;
};

export type ScopeLook = { x: number; y: number };

export const DEFAULT_COLORS: ScopePinColor[] = [
  { id: 'blue', label: 'Blue', color: '#86b8ff' },
  { id: 'red', label: 'Red', color: '#ff978b' },
  { id: 'yellow', label: 'Yellow', color: '#ffd98f' },
  { id: 'green', label: 'Green', color: '#9df0b5' },
  { id: 'pink', label: 'Pink', color: '#f095ec' },
];

export const DEFAULT_PINS: ScopePin[] = [
  { id: 'blue-1', color: 'blue', x: 620, y: 560 },
  { id: 'red-1', color: 'red', x: 1890, y: 318 },
];

export { WORLD };

export type ScopeViewProps = {
  className?: string;
  /** The screen's title, in the top bar. */
  title?: string;
  /** The pin colours of the palette, in order. */
  colors?: ScopePinColor[];
  /** The pins already placed. */
  defaultPins?: ScopePin[];
  /** How many pins of each colour can stand at once. */
  pinLimit?: number;
  /** Where the reticle points at first, in world units. */
  defaultLook?: ScopeLook;
  /** The zoom it opens at, from 1 to maxZoom. */
  defaultZoom?: number;
  /** The strongest zoom. */
  maxZoom?: number;
  /** Whether the lens is raised at first. */
  defaultOpen?: boolean;
  /** Shows the corner minimap. */
  showMinimap?: boolean;
  /** Called with every pin after one is placed or removed. */
  onPinsChange?: (pins: ScopePin[]) => void;
  /** Called when the zoom target changes. */
  onZoomChange?: (zoom: number) => void;
  /** Called when the lens closes (B, Escape or the Quit hint). */
  onQuit?: () => void;
  /** Called when the lens is raised again. */
  onRaise?: () => void;
  /** Plays a short look-around while on screen and untouched. */
  autoplay?: boolean;
  colorScheme?: 'light' | 'dark' | 'system';
};

/** World units across the lens at 1×. */
const SPAN = 900;
/** Compass bearing of the world's middle column, and how many degrees the world spans. */
const HEADING = 64;
const SWEEP = 150;
/** Easing rates (per second) of the view: input is snappy, the demo glides. */
const SNAP = 16;
const GLIDE = 2.4;

type View = { x: number; y: number; z: number };
type Target = View & { k: number };

function clampView(view: View, maxZoom: number): View {
  const z = Math.min(maxZoom, Math.max(1, view.z));
  const half = SPAN / 2 / z;
  const tall = 330 / z;
  return {
    z,
    x: Math.min(WORLD.width - half, Math.max(half, view.x)),
    y: Math.min(WORLD.height - tall, Math.max(tall, view.y)),
  };
}

const bearing = (x: number) => HEADING + ((x - WORLD.width / 2) / WORLD.width) * SWEEP;
const reach = (y: number) => Math.min(0.9, Math.max(0.16, 1 - (y - 340) / 720));
const rad = (deg: number) => (deg * Math.PI) / 180;

type Step =
  | { at: number; key: 'reset' | 'place' | 'edit' | 'quit' | 'raise' }
  | { at: number; key: 'look'; x: number; y: number }
  | { at: number; key: 'zoom'; z: number };

/** The demo: a short look-around, as actions at ms from its start. */
const DEMO: Step[] = [
  { at: 0, key: 'reset' },
  { at: 500, key: 'raise' },
  { at: 1500, key: 'look', x: 1442, y: 660 },
  { at: 3000, key: 'zoom', z: 3.2 },
  { at: 4400, key: 'place' },
  { at: 5500, key: 'zoom', z: 1.4 },
  { at: 6100, key: 'look', x: 1004, y: 262 },
  { at: 7900, key: 'edit' },
  { at: 8500, key: 'zoom', z: 2.6 },
  { at: 9700, key: 'place' },
  { at: 10800, key: 'zoom', z: 1 },
  { at: 11200, key: 'look', x: 1442, y: 660 },
  { at: 13000, key: 'place' },
  { at: 14000, key: 'look', x: 1200, y: 470 },
  { at: 15600, key: 'quit' },
];
const DEMO_LENGTH = 17200;

const LOOK_KEYS: Record<string, [number, number]> = {
  ArrowLeft: [-1, 0],
  ArrowRight: [1, 0],
  ArrowUp: [0, -1],
  ArrowDown: [0, 1],
};

export function ScopeView({
  className,
  title = 'Scope',
  colors = DEFAULT_COLORS,
  defaultPins = DEFAULT_PINS,
  pinLimit = 1,
  defaultLook = { x: 1200, y: 470 },
  defaultZoom = 1,
  maxZoom = 8,
  defaultOpen = true,
  showMinimap = true,
  onPinsChange,
  onZoomChange,
  onQuit,
  onRaise,
  autoplay = true,
  colorScheme,
}: ScopeViewProps) {
  const rawId = useId();
  const id = 'sv' + rawId.replace(/[^a-zA-Z0-9_-]/g, '');
  const { ref, take, playing, visible, reduced } = useAutoplay<HTMLDivElement>();
  const demo = playing && autoplay;
  const top = Math.max(1, maxZoom);

  const [layoutName, setLayoutName] = useState<Layout['name']>('wide');
  const layout = LAYOUTS[layoutName];
  useLayoutEffect(() => {
    const element = ref.current;
    if (!element) return;
    const observer = new ResizeObserver(([entry]) =>
      setLayoutName(entry.contentRect.width < 640 ? 'narrow' : 'wide'),
    );
    observer.observe(element);
    return () => observer.disconnect();
  }, [ref]);

  // Only the first render's defaults count.
  const [start] = useState(() => {
    const view = clampView({ x: defaultLook.x, y: defaultLook.y, z: defaultZoom }, top);
    const used = (color: string) => defaultPins.filter((pin) => pin.color === color).length;
    const free = colors.findIndex((color) => used(color.id) < pinLimit);
    return { view, pins: defaultPins, selected: Math.max(0, free) };
  });

  const [view, setView] = useState<View>(start.view);
  const [zooming, setZooming] = useState(false);
  const [open, setOpen] = useState(defaultOpen);
  const [pins, setPins] = useState<ScopePin[]>(start.pins);
  const [leaving, setLeaving] = useState<ScopePin[]>([]);
  const [dropped, setDropped] = useState<string | null>(null);
  const [selected, setSelected] = useState(start.selected);
  const [ripples, setRipples] = useState(0);
  const [said, setSaid] = useState('');

  // ---- The view: one loop that eases the view to its target, running only while it moves.
  const current = useRef<View>(start.view);
  const target = useRef<Target>({ ...start.view, k: SNAP });
  const frame = useRef(0);
  const last = useRef(0);

  const tick = useEffectEvent((now: number) => {
    const dt = Math.min(0.05, (now - (last.current || now)) / 1000) || 1 / 60;
    last.current = now;
    const goal = target.current;
    const from = current.current;
    const a = 1 - Math.exp(-goal.k * dt);
    // Zoom eases in log space, so each doubling takes the same time.
    const lz = Math.log(from.z) + (Math.log(goal.z) - Math.log(from.z)) * Math.min(1, a * 1.15);
    const next = {
      x: from.x + (goal.x - from.x) * a,
      y: from.y + (goal.y - from.y) * a,
      z: Math.exp(lz),
    };
    const done =
      Math.abs(goal.x - next.x) < 0.2 &&
      Math.abs(goal.y - next.y) < 0.2 &&
      Math.abs(Math.log(goal.z / next.z)) < 0.002;
    current.current = done ? { x: goal.x, y: goal.y, z: goal.z } : next;
    setView(current.current);
    setZooming(Math.abs(Math.log(goal.z / current.current.z)) > 0.01);
    frame.current = done ? 0 : requestAnimationFrame(tick);
  });

  const steer = useCallback(
    (to: Partial<View>, k = SNAP) => {
      const goal = target.current;
      const next = clampView({ x: to.x ?? goal.x, y: to.y ?? goal.y, z: to.z ?? goal.z }, top);
      if (to.z !== undefined && Math.abs(next.z - goal.z) > 1e-4) onZoomChange?.(next.z);
      target.current = { ...next, k };
      if (reduced) {
        cancelAnimationFrame(frame.current);
        frame.current = 0;
        current.current = next;
        setView(next);
        setZooming(false);
        return;
      }
      if (!frame.current) {
        last.current = 0;
        frame.current = requestAnimationFrame(tick);
      }
    },
    [top, reduced, onZoomChange],
  );
  useEffect(() => () => cancelAnimationFrame(frame.current), []);

  // ---- Pins.
  const lensR = layout.lens.r;
  const scale = ((2 * lensR) / SPAN) * view.z;
  const toLens = (pin: { x: number; y: number }) => ({
    x: (pin.x - view.x) * scale,
    y: (pin.y - view.y) * scale,
  });
  const markerScale = layout.name === 'wide' ? 1 : 0.8;
  const aimed = open
    ? pins.find((pin) => {
        const p = toLens(pin);
        return Math.hypot(p.x, p.y) < 16 || Math.hypot(p.x, p.y + 27 * markerScale) < 13;
      })
    : undefined;

  const colorIndex = (colorId: string) => colors.findIndex((color) => color.id === colorId);
  const usedOf = (list: ScopePin[], colorId: string) =>
    list.filter((pin) => pin.color === colorId).length;
  const nextFree = (list: ScopePin[], from: number) => {
    for (let step = 0; step < colors.length; step++) {
      const at = (from + step) % colors.length;
      if (usedOf(list, colors[at].id) < pinLimit) return at;
    }
    return -1;
  };
  const left = (list: ScopePin[]) =>
    colors.reduce((sum, color) => sum + Math.max(0, pinLimit - usedOf(list, color.id)), 0);

  const commit = (list: ScopePin[]) => {
    setPins(list);
    onPinsChange?.(list);
  };

  const paletteRef = useRef<HTMLDivElement>(null);
  const refuse = (message: string) => {
    setSaid(message);
    if (reduced) return;
    // The palette shakes: no pin of any colour is left to place or choose.
    paletteRef.current?.animate(
      [
        { transform: 'translateX(0)' },
        { transform: 'translateX(-5px)' },
        { transform: 'translateX(4px)' },
        { transform: 'translateX(-2px)' },
        { transform: 'translateX(0)' },
      ],
      { duration: 320, easing: 'ease-out' },
    );
  };

  const place = () => {
    const at = nextFree(pins, selected);
    if (at < 0) {
      refuse('No pins left. Aim at a pin and press A to take it back.');
      return;
    }
    const color = colors[at];
    const goal = target.current;
    let n = 1;
    while (pins.some((pin) => pin.id === `${color.id}-${n}`)) n++;
    const pin: ScopePin = {
      id: `${color.id}-${n}`,
      color: color.id,
      x: Math.round(goal.x),
      y: Math.round(goal.y),
    };
    const list = [...pins, pin];
    commit(list);
    setDropped(pin.id);
    setRipples((count) => count + 1);
    const following = nextFree(list, at);
    setSelected(following < 0 ? at : following);
    const rest = left(list);
    setSaid(
      `${color.label} pin placed on ${landmarkAt(pin.x, pin.y)}. ${rest ? `${rest} left.` : 'No pins left.'}`,
    );
  };

  const remove = (pin: ScopePin) => {
    const list = pins.filter((item) => item.id !== pin.id);
    commit(list);
    if (!reduced) setLeaving((now) => [...now, pin]);
    if (nextFree(pins, selected) < 0) setSelected(Math.max(0, colorIndex(pin.color)));
    setSaid(`${colors[colorIndex(pin.color)]?.label ?? ''} pin removed.`);
  };

  const raise = () => {
    if (open) return;
    setOpen(true);
    setSaid('Scope raised.');
    onRaise?.();
  };

  const quit = () => {
    if (!open) return;
    setOpen(false);
    setSaid('Scope lowered.');
    onQuit?.();
  };

  const act = () => {
    if (!open) raise();
    else if (aimed) remove(aimed);
    else place();
  };

  const edit = (to?: number) => {
    if (!open) return;
    const at = to ?? nextFree(pins, (selected + 1) % colors.length);
    if (at < 0 || usedOf(pins, colors[at].id) >= pinLimit) {
      refuse('No pins left to choose.');
      return;
    }
    setSelected(at);
    setSaid(`${colors[at].label} pin selected.`);
  };

  const onPaletteKey = (event: KeyboardEvent<HTMLDivElement>) => {
    const dir =
      event.key === 'ArrowRight' || event.key === 'ArrowDown'
        ? 1
        : event.key === 'ArrowLeft' || event.key === 'ArrowUp'
          ? -1
          : 0;
    if (!dir) return;
    event.preventDefault();
    event.stopPropagation();
    take();
    for (let step = 1; step <= colors.length; step++) {
      const at = (((selected + dir * step) % colors.length) + colors.length) % colors.length;
      if (usedOf(pins, colors[at].id) < pinLimit) {
        edit(at);
        paletteRef.current?.querySelectorAll('button')[at]?.focus();
        return;
      }
    }
  };

  // ---- The demo, on its own clock: it restarts from the top each time it is handed back.
  const step = useEffectEvent((item: Step) => {
    if (item.key === 'reset') {
      setPins(start.pins);
      setLeaving([]);
      setSelected(start.selected);
      setOpen(false);
      steer(start.view, GLIDE);
    } else if (item.key === 'look') steer({ x: item.x, y: item.y }, GLIDE);
    else if (item.key === 'zoom') steer({ z: item.z }, 3.2);
    else if (item.key === 'place') act();
    else if (item.key === 'edit') edit();
    else if (item.key === 'quit') quit();
    else raise();
  });
  useEffect(() => {
    if (!demo) return;
    const ids: number[] = [];
    const run = (from: number) => {
      for (const item of DEMO) ids.push(window.setTimeout(() => step(item), from + item.at));
      ids.push(window.setTimeout(() => run(0), from + DEMO_LENGTH));
    };
    run(0);
    return () => ids.forEach((timer) => window.clearTimeout(timer));
  }, [demo]);

  // ---- Input.
  const lensRef = useRef<HTMLDivElement>(null);
  const drag = useRef<{ id: number; x: number; y: number; moved: boolean } | null>(null);

  /** World units per CSS pixel at the current zoom. */
  const perPixel = () => {
    const width = lensRef.current?.getBoundingClientRect().width || 2 * lensR;
    return SPAN / current.current.z / width;
  };

  const onLensDown = (event: ReactPointerEvent<HTMLDivElement>) => {
    take();
    if (!open || event.button !== 0) return;
    event.currentTarget.setPointerCapture(event.pointerId);
    drag.current = { id: event.pointerId, x: event.clientX, y: event.clientY, moved: false };
  };
  const onLensMove = (event: ReactPointerEvent<HTMLDivElement>) => {
    const now = drag.current;
    if (!now || now.id !== event.pointerId) return;
    const dx = event.clientX - now.x;
    const dy = event.clientY - now.y;
    if (!now.moved && Math.hypot(dx, dy) < 4) return;
    now.moved = true;
    now.x = event.clientX;
    now.y = event.clientY;
    const k = perPixel();
    const goal = target.current;
    steer({ x: goal.x - dx * k, y: goal.y - dy * k }, 22);
  };
  const onLensUp = (event: ReactPointerEvent<HTMLDivElement>) => {
    const now = drag.current;
    drag.current = null;
    if (!now || now.moved || now.id !== event.pointerId) return;
    // A tap turns the scope to the point tapped.
    const box = event.currentTarget.getBoundingClientRect();
    const k = perPixel();
    const goal = target.current;
    steer(
      {
        x: goal.x + (event.clientX - (box.left + box.width / 2)) * k,
        y: goal.y + (event.clientY - (box.top + box.height / 2)) * k,
      },
      7,
    );
  };

  const onWheel = useEffectEvent((event: WheelEvent) => {
    if (!open) return;
    event.preventDefault();
    take();
    const goal = target.current;
    steer({ z: goal.z * Math.exp(-event.deltaY * 0.0022) }, 12);
  });
  useEffect(() => {
    const element = lensRef.current;
    if (!element) return;
    const listener = (event: WheelEvent) => onWheel(event);
    element.addEventListener('wheel', listener, { passive: false });
    return () => element.removeEventListener('wheel', listener);
  }, []);

  const zoomBy = (factor: number) => steer({ z: target.current.z * factor }, 10);

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    take();
    if (event.altKey || event.ctrlKey || event.metaKey) return;
    const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
    const onButton = (event.target as HTMLElement).tagName === 'BUTTON';
    const look = LOOK_KEYS[key];
    if (look && open && !onButton) {
      event.preventDefault();
      const goal = target.current;
      const stepBy = (event.shiftKey ? 140 : 50) / goal.z;
      steer({ x: goal.x + look[0] * stepBy, y: goal.y + look[1] * stepBy }, 9);
    } else if ((key === 'e' || key === 'PageUp' || key === '+' || key === '=') && open) {
      event.preventDefault();
      zoomBy(1.4);
    } else if (key === 'Home' && open && !onButton) {
      event.preventDefault();
      steer(start.view, 8);
      setSaid('View reset.');
    } else if ((key === 'q' || key === 'PageDown') && open) {
      event.preventDefault();
      zoomBy(1 / 1.4);
    } else if (key === 'a' || (!onButton && (key === 'Enter' || key === ' '))) {
      event.preventDefault();
      act();
    } else if (key === '-' || key === 'p') {
      event.preventDefault();
      edit();
    } else if (key === 'b' || key === 'Escape') {
      event.preventDefault();
      quit();
    }
  };

  // ---- The zoom gauge.
  const gaugeRef = useRef<HTMLDivElement>(null);
  const zoomAt = (clientY: number) => {
    const box = gaugeRef.current?.getBoundingClientRect();
    if (!box) return;
    const t = 1 - Math.min(1, Math.max(0, (clientY - box.top) / box.height));
    steer({ z: Math.exp(t * Math.log(top)) }, 14);
  };
  const onGaugeKey = (event: KeyboardEvent<HTMLDivElement>) => {
    const map: Record<string, number> = {
      ArrowUp: 1.25,
      ArrowRight: 1.25,
      ArrowDown: 0.8,
      ArrowLeft: 0.8,
      PageUp: 2,
      PageDown: 0.5,
    };
    if (map[event.key]) {
      event.preventDefault();
      event.stopPropagation();
      take();
      zoomBy(map[event.key]);
    } else if (event.key === 'Home' || event.key === 'End') {
      event.preventDefault();
      event.stopPropagation();
      take();
      steer({ z: event.key === 'Home' ? 1 : top }, 10);
    }
  };
  const gaugeT = top > 1 ? Math.log(view.z) / Math.log(top) : 0;

  // ---- Drawing.
  const { lens, mini } = layout;
  const yaw = bearing(view.x);
  const cone = Math.min(40, ((SPAN / view.z / WORLD.width) * SWEEP) / 2);
  const coneD = `M0 0 L${Math.sin(rad(-cone)) * 0.92} ${-Math.cos(rad(-cone)) * 0.92} A0.92 0.92 0 0 1 ${Math.sin(rad(cone)) * 0.92} ${-Math.cos(rad(cone)) * 0.92} Z`;
  const freeLeft = left(pins);
  const selectedFree = usedOf(pins, colors[selected]?.id ?? '') < pinLimit;

  const vars = {
    '--w': layout.w,
    '--h': layout.h,
    '--bar': layout.bar,
    '--lx': lens.x,
    '--ly': lens.y,
    '--lr': lens.r,
    '--mx': mini.x,
    '--my': mini.y,
    '--mr': mini.r,
    '--py': layout.palette.y,
    '--gx': layout.gauge.x,
    '--gy0': layout.gauge.y0,
    '--gy1': layout.gauge.y1,
    '--hr': layout.hints.right,
    '--sel': selected,
    '--count': colors.length,
    '--gauge-t': gaugeT,
  } as CSSProperties;

  const markerColor = (pin: ScopePin) => colors[colorIndex(pin.color)]?.color ?? '#fff';
  const markerMark = (pin: ScopePin) => Math.max(0, colorIndex(pin.color));

  return (
    <div
      ref={ref}
      className={`${styles.root} ${className ?? ''}`}
      data-color-scheme={colorScheme}
      data-part="root"
      onPointerDown={take}
    >
      <div
        className={styles.screen}
        style={vars}
        data-layout={layout.name}
        data-open={open || undefined}
        data-idle={(visible && !reduced) || undefined}
        onKeyDown={onKeyDown}
        data-part="screen"
      >
        <div className={styles.surface}>
          <Backdrop id={id} layout={layout} />
        </div>

        <div
          ref={lensRef}
          className={styles.lens}
          role="application"
          aria-roledescription="scope"
          aria-label={`${title}: arrows look around, Q and E zoom, A places or removes a pin, minus changes the pin colour, B lowers the scope`}
          aria-describedby={`${id}-status`}
          tabIndex={0}
          onPointerDown={onLensDown}
          onPointerMove={onLensMove}
          onPointerUp={onLensUp}
          onPointerCancel={() => (drag.current = null)}
          data-part="lens"
        >
          <svg viewBox={`${-lensR} ${-lensR} ${2 * lensR} ${2 * lensR}`} aria-hidden="true">
            <defs>
              <clipPath id={`${id}-clip`}>
                <circle r={lensR} />
              </clipPath>
              <radialGradient
                id={`${id}-ring`}
                gradientUnits="userSpaceOnUse"
                cx="0"
                cy="0"
                r={lensR}
              >
                <stop offset="0.885" stopColor="#000" />
                <stop offset="0.9" stopColor="#fff" />
              </radialGradient>
              <mask id={`${id}-edge`}>
                <circle r={lensR} fill={`url(#${id}-ring)`} />
              </mask>
              <radialGradient
                id={`${id}-vignette`}
                gradientUnits="userSpaceOnUse"
                cx="0"
                cy="0"
                r={lensR}
              >
                <stop offset="0.6" stopColor="#0a1a24" stopOpacity="0" />
                <stop offset="0.92" stopColor="#0a1a24" stopOpacity="0.22" />
                <stop offset="1" stopColor="#0a1a24" stopOpacity="0.5" />
              </radialGradient>
              <pattern
                id={`${id}-scan`}
                width="10.5"
                height="20"
                patternUnits="userSpaceOnUse"
                x={-lensR}
              >
                <rect x="4.6" width="1.4" height="20" fill="#bfe6e4" />
              </pattern>
            </defs>
            <g clipPath={`url(#${id}-clip)`}>
              <g
                id={`${id}-world`}
                transform={`translate(${-view.x * scale} ${-view.y * scale}) scale(${scale})`}
              >
                <Landscape id={id} />
              </g>
              <g mask={`url(#${id}-edge)`} data-part="edge-distortion">
                <use href={`#${id}-world`} transform="scale(1.045)" />
              </g>
              <circle r={lensR} fill="#5fb7b4" opacity="0.07" />
              <rect
                className={styles.scan}
                x={-lensR}
                y={-lensR}
                width={2 * lensR}
                height={2 * lensR}
                fill={`url(#${id}-scan)`}
                data-part="scanlines"
              />
              <circle r={lensR} fill={`url(#${id}-vignette)`} />
              <g data-part="pins">
                {pins.map((pin) => {
                  const p = toLens(pin);
                  if (Math.hypot(p.x, p.y) > lensR + 60) return null;
                  return (
                    <g key={pin.id} transform={`translate(${p.x} ${p.y}) scale(${markerScale})`}>
                      <g
                        className={styles.pin}
                        data-dropped={dropped === pin.id || undefined}
                        data-aimed={aimed?.id === pin.id || undefined}
                        data-part="pin"
                      >
                        <PinMarker color={markerColor(pin)} mark={markerMark(pin)} />
                      </g>
                    </g>
                  );
                })}
                {leaving.map((pin) => {
                  const p = toLens(pin);
                  return (
                    <g
                      key={`gone-${pin.id}`}
                      transform={`translate(${p.x} ${p.y}) scale(${markerScale})`}
                    >
                      <g
                        className={styles.pin}
                        data-leaving
                        onAnimationEnd={() =>
                          setLeaving((now) => now.filter((item) => item.id !== pin.id))
                        }
                      >
                        <PinMarker color={markerColor(pin)} mark={markerMark(pin)} />
                      </g>
                    </g>
                  );
                })}
              </g>
              <circle
                r={lensR - 0.8}
                fill="none"
                stroke="#c4dcd6"
                strokeWidth="1.6"
                opacity="0.55"
              />
              <circle r={lensR - 5} fill="none" stroke="#3cd3fc" strokeWidth="2" opacity="0.16" />
            </g>
          </svg>
        </div>

        <div
          className={styles.reticle}
          data-zooming={zooming || undefined}
          data-aimed={aimed ? true : undefined}
          aria-hidden="true"
          data-part="reticle"
        >
          <svg viewBox="-36 -20 72 40">
            <path className={styles.arcLeft} d="M-18.7 15 A24 24 0 0 1 -18.7 -15" />
            <path className={styles.arcRight} d="M18.7 -15 A24 24 0 0 1 18.7 15" />
            <circle className={styles.disc} r="7" />
            <circle className={styles.dot} r="2.6" />
          </svg>
          {ripples > 0 && <span key={ripples} className={styles.ripple} />}
        </div>

        <div className={styles.bezel}>
          <Bezel id={id} layout={layout} />
        </div>

        <h2 className={styles.title} data-part="title">
          {title}
        </h2>

        <div
          ref={gaugeRef}
          className={styles.gauge}
          role="slider"
          tabIndex={open ? 0 : -1}
          aria-label="Zoom"
          aria-orientation="vertical"
          aria-valuemin={1}
          aria-valuemax={top}
          aria-valuenow={Math.round(view.z * 10) / 10}
          aria-valuetext={`${view.z.toFixed(1)} times`}
          aria-disabled={!open || undefined}
          onKeyDown={onGaugeKey}
          onPointerDown={(event) => {
            take();
            if (!open) return;
            event.currentTarget.setPointerCapture(event.pointerId);
            zoomAt(event.clientY);
          }}
          onPointerMove={(event) => {
            if (event.currentTarget.hasPointerCapture(event.pointerId)) zoomAt(event.clientY);
          }}
          data-part="zoom-gauge"
        >
          <svg
            className={styles.rails}
            viewBox="0 0 16 100"
            preserveAspectRatio="none"
            aria-hidden="true"
          >
            <rect x="3.4" y="3" width="1.4" height="94" />
            <rect x="11.2" y="3" width="1.4" height="94" />
          </svg>
          {(['top', 'bottom'] as const).map((end) => (
            <svg
              key={end}
              className={styles.cap}
              data-end={end}
              viewBox="0 0 16 8"
              aria-hidden="true"
            >
              <path d="M0.6 0 H7.6 L4.1 7 Z M8.4 0 H15.4 L11.9 7 Z" />
            </svg>
          ))}
          <span className={styles.fill} aria-hidden="true" />
          <span className={styles.thumb} aria-hidden="true" />
          <span className={styles.readout} aria-hidden="true">
            {view.z.toFixed(1)}×
          </span>
        </div>

        <div
          className={styles.palette}
          role="radiogroup"
          aria-label="Pin colour"
          ref={paletteRef}
          onKeyDown={onPaletteKey}
          data-part="pin-palette"
        >
          {colors.map((color, at) => {
            const used = usedOf(pins, color.id) >= pinLimit;
            return (
              <button
                key={color.id}
                type="button"
                role="radio"
                aria-checked={at === selected}
                aria-label={`${color.label}${used ? ', all placed' : ''}`}
                aria-disabled={used || undefined}
                tabIndex={at === selected ? 0 : -1}
                className={styles.stamp}
                data-used={used || undefined}
                style={{ '--pin': color.color } as CSSProperties}
                onClick={() => {
                  take();
                  if (!used) edit(at);
                }}
                data-part="pin-stamp"
              >
                <PinStamp mark={at} />
              </button>
            );
          })}
          <span
            className={styles.caret}
            data-hidden={!selectedFree || undefined}
            aria-hidden="true"
          />
        </div>

        {showMinimap && (
          <div className={styles.mini} aria-hidden="true" data-part="minimap">
            <svg viewBox="-1.16 -1.16 2.32 2.32">
              <defs>
                <radialGradient
                  id={`${id}-cone`}
                  r="1"
                  cx="0"
                  cy="0"
                  gradientUnits="userSpaceOnUse"
                >
                  <stop offset="0" stopColor="#fff6c8" stopOpacity="0.55" />
                  <stop offset="1" stopColor="#fff6c8" stopOpacity="0" />
                </radialGradient>
                <clipPath id={`${id}-mini-clip`}>
                  <circle r="1" />
                </clipPath>
              </defs>
              <path d="M-0.12 -1.0 L0 -1.15 L0.12 -1.0 Z" fill="#14172c" />
              <g clipPath={`url(#${id}-mini-clip)`}>
                <MiniGround id={id} />
                <path d={coneD} fill={`url(#${id}-cone)`} transform={`rotate(${yaw})`} />
                {pins.map((pin) => {
                  const b = rad(bearing(pin.x));
                  const d = reach(pin.y);
                  return (
                    <circle
                      key={pin.id}
                      className={styles.miniPin}
                      cx={Math.sin(b) * d}
                      cy={-Math.cos(b) * d}
                      r="0.075"
                      fill={markerColor(pin)}
                      stroke="#14172c"
                      strokeWidth="0.025"
                    />
                  );
                })}
              </g>
              <circle r="1" fill="none" stroke="#2b2512" strokeWidth="0.03" />
              <g transform={`rotate(${yaw})`}>
                <path
                  d="M0 -0.16 L0.11 0.1 L0 0.05 L-0.11 0.1 Z"
                  fill="#ffe25e"
                  stroke="#7a5a00"
                  strokeWidth="0.02"
                />
              </g>
            </svg>
          </div>
        )}

        <div className={styles.hints} data-part="hints">
          {open && (
            <>
              <button
                type="button"
                className={styles.hint}
                data-part="hint"
                aria-keyshortcuts="- P"
                onClick={() => {
                  take();
                  edit();
                }}
              >
                <span>Edit pins</span>
                <FaceGlyph letter="-" />
              </button>
              <button
                type="button"
                className={styles.hint}
                data-part="hint"
                aria-keyshortcuts="B Escape"
                onClick={() => {
                  take();
                  quit();
                }}
              >
                <span>Quit</span>
                <FaceGlyph letter="B" />
              </button>
            </>
          )}
          <button
            type="button"
            className={styles.hint}
            data-part="hint"
            aria-keyshortcuts="A"
            aria-disabled={(open && !aimed && freeLeft === 0) || undefined}
            onClick={() => {
              take();
              act();
            }}
          >
            <span>{!open ? 'Raise Scope' : aimed ? 'Remove Pin' : 'Place Pin'}</span>
            <FaceGlyph letter="A" />
          </button>
        </div>

        <p className={styles.live} id={`${id}-status`} aria-live="polite">
          {said}
        </p>
      </div>
    </div>
  );
}
```

### scope-view/landscape.tsx

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

import { memo } from 'react';

/*
 * The painted world seen through the scope: one SVG group in world units, 2400 wide and 1000
 * tall (x to the right, y down; the horizon sits near y 350). It is a sunset over a plain: a
 * smoking peak with a glowing crater on the left of centre, table mesas on the right, a river
 * that catches the light, a pool, a lattice watchtower with a beacon, falls in the western
 * cliffs, and woods scattered over the grass.
 *
 * Ridges and woods are generated once, at load, from a seeded random sequence (midpoint
 * displacement for ridges), so the server and the browser draw the same thing and nothing is
 * computed again while the view moves. Everything is vector: the scope zooms by scaling this
 * group, so it stays sharp at any zoom.
 */

export const WORLD = { width: 2400, height: 1000, horizon: 350 };

/** Named places, for what the reticle is on: the nearest one within its radius. */
export const LANDMARKS = [
  { name: 'the ember peak', x: 1002, y: 250, r: 170 },
  { name: 'the watchtower', x: 1442, y: 668, r: 70 },
  { name: 'the river bend', x: 1610, y: 560, r: 110 },
  { name: 'the sunlit pool', x: 1060, y: 688, r: 120 },
  { name: 'the table mesas', x: 1960, y: 360, r: 230 },
  { name: 'the western falls', x: 420, y: 470, r: 140 },
  { name: 'the twin crags', x: 330, y: 330, r: 170 },
  { name: 'the old arch', x: 2170, y: 540, r: 80 },
];

export function landmarkAt(x: number, y: number) {
  let best: { name: string; d: number } | null = null;
  for (const mark of LANDMARKS) {
    const d = Math.hypot(x - mark.x, y - mark.y);
    if (d <= mark.r && (!best || d < best.d)) best = { name: mark.name, d };
  }
  if (best) return best.name;
  return y < WORLD.horizon - 20 ? 'the sky' : y < 520 ? 'the far hills' : 'the plain';
}

type Pt = [number, number];

function random(seed: number) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const r1 = (n: number) => Math.round(n * 10) / 10;

/** A ridgeline through `points`, roughened by midpoint displacement. */
function ridge(points: Pt[], rough: number, depth: number, seed: number): Pt[] {
  const rand = random(seed);
  let line = points;
  for (let level = 0; level < depth; level++) {
    const next: Pt[] = [line[0]];
    for (let i = 1; i < line.length; i++) {
      const [x0, y0] = line[i - 1];
      const [x1, y1] = line[i];
      const span = Math.hypot(x1 - x0, y1 - y0);
      next.push([(x0 + x1) / 2, (y0 + y1) / 2 + (rand() - 0.5) * span * rough], line[i]);
    }
    line = next;
    rough *= 0.62;
  }
  return line;
}

/** A closed shape: the ridge, then down to `floor` and back. */
function massif(points: Pt[], floor: number) {
  const first = points[0];
  const last = points[points.length - 1];
  return (
    `M${r1(first[0])} ${floor}` +
    points.map(([x, y]) => `L${r1(x)} ${r1(y)}`).join('') +
    `L${r1(last[0])} ${floor}Z`
  );
}

const line = (points: Pt[]) =>
  points.map(([x, y], i) => `${i ? 'L' : 'M'}${r1(x)} ${r1(y)}`).join('');

/** Clumps of trees: rounded crowns, back to front, in a band. */
function woods(seed: number, count: number, x0: number, x1: number, y0: number, y1: number) {
  const rand = random(seed);
  const crowns: { x: number; y: number; r: number; tone: number }[] = [];
  for (let i = 0; i < count; i++) {
    const cx = x0 + rand() * (x1 - x0);
    const cy = y0 + rand() * (y1 - y0);
    // Further away (higher up) means smaller.
    const scale = 0.45 + ((cy - WORLD.horizon) / (WORLD.height - WORLD.horizon)) * 1.2;
    const n = 2 + Math.floor(rand() * 4);
    for (let j = 0; j < n; j++) {
      crowns.push({
        x: r1(cx + (rand() - 0.5) * 22 * scale),
        y: r1(cy + (rand() - 0.5) * 6 * scale),
        r: r1((4 + rand() * 5) * scale),
        tone: Math.floor(rand() * 3),
      });
    }
  }
  return crowns.sort((a, b) => a.y - b.y);
}

const FAR_RANGE = massif(
  ridge(
    [
      [0, 352],
      [300, 318],
      [620, 344],
      [900, 330],
      [1300, 356],
      [1700, 340],
      [2100, 352],
      [2400, 336],
    ],
    0.08,
    6,
    3,
  ),
  470,
);

const TWIN_CRAGS = massif(
  ridge(
    [
      [40, 470],
      [170, 360],
      [250, 290],
      [300, 262],
      [345, 300],
      [380, 286],
      [418, 250],
      [470, 312],
      [600, 380],
      [720, 452],
    ],
    0.07,
    5,
    11,
  ),
  520,
);

const PEAK_POINTS: Pt[] = [
  [600, 456],
  [690, 404],
  [780, 352],
  [850, 306],
  [900, 270],
  [944, 240],
  [972, 218],
  [990, 204],
  [1000, 212],
  [1014, 198],
  [1030, 214],
  [1056, 238],
  [1094, 270],
  [1136, 300],
  [1180, 322],
  [1230, 336],
  [1290, 346],
  [1360, 352],
  [1440, 360],
];
const SUMMIT = 9;
const PEAK_LINE = ridge(PEAK_POINTS, 0.32, 5, 21);
const PEAK = massif(PEAK_LINE, 520);
// The shaded east face: from the summit down the right flank, then back up a gully.
const PEAK_SHADE = massif(
  [
    ...PEAK_LINE.slice(SUMMIT * 32),
    [1330, 420],
    [1220, 400],
    [1140, 352],
    [1080, 300],
    [1036, 240],
  ],
  520,
);
// Crags in front of the peak's foot, darker, for depth.
const CRAGS = massif(
  ridge(
    [
      [700, 470],
      [760, 418],
      [800, 396],
      [836, 420],
      [880, 380],
      [930, 404],
      [980, 372],
      [1040, 398],
      [1100, 366],
      [1150, 392],
      [1210, 376],
      [1280, 402],
      [1360, 392],
      [1460, 430],
      [1520, 470],
    ],
    0.3,
    4,
    27,
  ),
  520,
);
// Ridges that run down the faces.
const PEAK_RIDGES = [
  [
    [990, 206],
    [962, 252],
    [912, 318],
    [866, 400],
  ],
  [
    [1000, 214],
    [994, 270],
    [972, 340],
    [960, 420],
  ],
  [
    [1014, 202],
    [1040, 262],
    [1072, 330],
    [1090, 400],
  ],
  [
    [972, 220],
    [930, 268],
    [880, 300],
    [800, 360],
  ],
  [
    [1056, 240],
    [1120, 300],
    [1180, 340],
    [1240, 380],
  ],
].map((points, i) => line(ridge(points as Pt[], 0.1, 4, 31 + i)));
const LAVA = [
  ridge(
    [
      [996, 210],
      [985, 240],
      [962, 262],
      [948, 300],
    ],
    0.12,
    3,
    41,
  ),
  ridge(
    [
      [1010, 206],
      [1022, 246],
      [1018, 278],
    ],
    0.14,
    3,
    43,
  ),
].map(line);

// Table mesas: flat tops, sheer sides.
const MESAS = [
  'M1540 470 L1560 352 L1574 340 L1660 338 L1676 350 L1690 412 L1720 470 Z',
  'M1700 470 L1736 330 L1752 318 L1890 316 L1906 326 L1930 400 L1960 470 Z',
  'M1930 470 L1964 344 L1976 336 L2080 336 L2094 346 L2120 420 L2140 470 Z',
  'M2160 470 L2200 300 L2216 290 L2330 288 L2344 300 L2370 400 L2400 430 L2400 470 Z',
];
const MESA_TOPS = [
  'M1574 340 L1660 338',
  'M1752 318 L1890 316',
  'M1976 336 L2080 336',
  'M2216 290 L2330 288',
];

const HILLS = massif(
  ridge(
    [
      [0, 470],
      [200, 440],
      [460, 452],
      [700, 430],
      [900, 448],
      [1150, 436],
      [1380, 452],
      [1560, 440],
      [1800, 456],
      [2050, 438],
      [2400, 452],
    ],
    0.05,
    6,
    51,
  ),
  560,
);

const NEAR_HILLS = massif(
  ridge(
    [
      [0, 540],
      [180, 500],
      [380, 516],
      [560, 498],
      [760, 520],
      [880, 548],
      [980, 600],
    ],
    0.06,
    5,
    61,
  ),
  640,
);

const RIVER =
  'M1716 452 C1700 470 1660 484 1640 500 C1620 516 1600 528 1612 546 C1626 566 1690 572 1730 588 ' +
  'C1790 610 1860 626 1960 640 C2100 660 2250 676 2400 690 L2400 726 C2240 712 2080 700 1940 682 ' +
  'C1820 666 1720 640 1660 610 C1600 582 1570 556 1584 530 C1598 504 1640 482 1704 452 Z';
const STREAM =
  'M440 520 C470 560 560 590 680 612 C780 630 860 646 920 664 L928 672 C860 660 770 646 670 630 ' +
  'C552 608 462 576 432 524 Z';
const POOL =
  'M902 668 C940 652 1020 646 1100 650 C1170 654 1222 664 1226 682 C1230 700 1176 716 1090 720 ' +
  'C1000 724 924 712 900 694 C888 684 890 674 902 668 Z';

const CLOUDS = [
  { x: 260, y: 92, w: 520, h: 30, o: 0.55 },
  { x: 900, y: 60, w: 760, h: 40, o: 0.6 },
  { x: 1500, y: 128, w: 640, h: 26, o: 0.45 },
  { x: 2100, y: 76, w: 520, h: 34, o: 0.55 },
  { x: 640, y: 168, w: 420, h: 16, o: 0.35 },
  { x: 1860, y: 196, w: 380, h: 14, o: 0.3 },
];

/** Rolling ground, back to front: each fold covers the plain down to the bottom. */
const FOLDS = [
  { y: 498, seed: 91, fill: '#6f8170' },
  { y: 560, seed: 93, fill: '#68796a' },
  { y: 640, seed: 95, fill: '#627466' },
  { y: 760, seed: 97, fill: '#5b6e61' },
].map(({ y, seed, fill }) => {
  const rand = random(seed);
  const points: Pt[] = [];
  for (let x = -400; x <= 2800; x += 200) points.push([x, y + (rand() - 0.5) * (y - 420) * 0.5]);
  return {
    d: massif(ridge(points, 0.1, 5, seed), 1400),
    crest: line(ridge(points, 0.1, 5, seed)),
    fill,
  };
});

const FAR_WOODS = woods(71, 80, 0, 2400, 452, 540);
const onWater = (x: number, y: number) =>
  ((x - 1064) / 180) ** 2 + ((y - 686) / 46) ** 2 < 1 ||
  (x > 1580 && y > 440 && y < 740 && Math.abs(y - (560 + (x - 1600) * 0.16)) < 46);
const MID_WOODS = woods(73, 70, 0, 2400, 560, 760).filter((tree) => !onWater(tree.x, tree.y));
const NEAR_WOODS = woods(79, 18, 0, 2400, 800, 960);

const PATCHES = (() => {
  const rand = random(83);
  return Array.from({ length: 26 }, () => {
    const y = 480 + rand() * 480;
    const depth = (y - WORLD.horizon) / (WORLD.height - WORLD.horizon);
    return {
      x: r1(rand() * 2400),
      y: r1(y),
      rx: r1((60 + rand() * 120) * (0.5 + depth)),
      ry: r1((6 + rand() * 10) * (0.5 + depth)),
      light: rand() > 0.45,
    };
  });
})();

function Tower({ glow }: { glow: string }) {
  // A lattice beacon tower standing on a stone footing, 140 units tall.
  return (
    <g data-part="tower">
      <ellipse cx="1442" cy="736" rx="34" ry="6" fill="#4a5a56" />
      <path d="M1424 736 L1460 736 L1456 724 L1428 724 Z" fill="#6d7d7f" />
      <path
        d="M1430 724 L1438 628 M1454 724 L1446 628 M1432 700 L1452 676 M1452 700 L1432 676 M1434 676 L1450 652 M1450 676 L1434 652 M1436 652 L1448 632 M1448 652 L1436 632"
        stroke="#8ea0a2"
        strokeWidth="2.4"
        fill="none"
        strokeLinecap="round"
      />
      <path d="M1428 628 L1456 628 L1452 616 L1432 616 Z" fill="#9db0b2" />
      <path d="M1434 616 L1442 588 L1450 616 Z" fill="#7b8d90" />
      <rect x="1440.6" y="572" width="2.8" height="18" fill="#9db0b2" />
      <circle className="beacon" cx="1442" cy="570" r="9" fill={glow} />
      <circle cx="1442" cy="570" r="2.6" fill="#d8fbff" />
      <ellipse cx="1442" cy="728" rx="24" ry="8" fill={glow} opacity="0.7" />
    </g>
  );
}

function Arch() {
  return (
    <path
      data-part="arch"
      d="M2140 560 L2142 516 C2146 494 2194 494 2198 516 L2200 560 L2188 560 L2186 522 C2182 508 2158 508 2154 522 L2152 560 Z"
      fill="#5b6a63"
    />
  );
}

/** The world, drawn once; `id` keeps the gradient ids unique per scope on the page. */
export const Landscape = memo(function Landscape({ id }: { id: string }) {
  const g = (name: string) => `${id}-${name}`;
  const url = (name: string) => `url(#${g(name)})`;
  return (
    <g>
      <defs>
        <linearGradient
          id={g('sky')}
          gradientUnits="userSpaceOnUse"
          x1="0"
          y1="-60"
          x2="0"
          y2="390"
        >
          <stop offset="0" stopColor="#2f4354" />
          <stop offset="0.42" stopColor="#4a6170" />
          <stop offset="0.6" stopColor="#7f9079" />
          <stop offset="0.76" stopColor="#d2b155" />
          <stop offset="0.9" stopColor="#f0c862" />
          <stop offset="1" stopColor="#d8c278" />
        </linearGradient>
        <radialGradient id={g('sun')} cx="0.5" cy="0.5" r="0.5">
          <stop offset="0" stopColor="#ffd77a" stopOpacity="0.85" />
          <stop offset="0.45" stopColor="#f2bf55" stopOpacity="0.4" />
          <stop offset="1" stopColor="#f2bf55" stopOpacity="0" />
        </radialGradient>
        <linearGradient id={g('peak')} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#5a6863" />
          <stop offset="1" stopColor="#4a5954" />
        </linearGradient>
        <linearGradient id={g('plain')} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#7c8a74" />
          <stop offset="0.3" stopColor="#728470" />
          <stop offset="1" stopColor="#647a6c" />
        </linearGradient>
        <linearGradient id={g('gold')} x1="0" y1="0" x2="1" y2="0">
          <stop offset="0" stopColor="#d9ac55" />
          <stop offset="0.5" stopColor="#e7bf68" />
          <stop offset="1" stopColor="#c09449" />
        </linearGradient>
        <radialGradient id={g('crater')} cx="0.5" cy="0.5" r="0.5">
          <stop offset="0" stopColor="#ff7a45" />
          <stop offset="0.4" stopColor="#e2512e" stopOpacity="0.8" />
          <stop offset="1" stopColor="#e2512e" stopOpacity="0" />
        </radialGradient>
        <radialGradient id={g('beacon')} cx="0.5" cy="0.5" r="0.5">
          <stop offset="0" stopColor="#8ff0ff" />
          <stop offset="0.45" stopColor="#3cd3fc" stopOpacity="0.55" />
          <stop offset="1" stopColor="#3cd3fc" stopOpacity="0" />
        </radialGradient>
        <linearGradient id={g('haze')} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#cdbf7c" stopOpacity="0" />
          <stop offset="0.6" stopColor="#cdbf7c" stopOpacity="0.38" />
          <stop offset="1" stopColor="#cdbf7c" stopOpacity="0" />
        </linearGradient>
      </defs>

      <rect x="-400" y="-400" width="3200" height="1000" fill={url('sky')} />
      <ellipse cx="1660" cy="330" rx="520" ry="150" fill={url('sun')} />
      {CLOUDS.map((cloud, i) => (
        <g key={i} opacity={cloud.o}>
          <ellipse cx={cloud.x} cy={cloud.y} rx={cloud.w / 2} ry={cloud.h / 2} fill="#3a4e55" />
          <ellipse
            cx={cloud.x + 14}
            cy={cloud.y + cloud.h * 0.36}
            rx={cloud.w * 0.42}
            ry={cloud.h * 0.18}
            fill="#e6b45a"
            opacity="0.7"
          />
        </g>
      ))}

      <path d={FAR_RANGE} fill="#7f8a73" />
      <rect x="-400" y="300" width="3200" height="110" fill={url('haze')} />
      {MESAS.map((d, i) => (
        <path key={i} d={d} fill={i % 2 ? '#6a776a' : '#72806f'} />
      ))}
      {MESA_TOPS.map((d, i) => (
        <path key={i} d={d} stroke="#e1bd67" strokeWidth="3" strokeLinecap="round" />
      ))}
      <path d={TWIN_CRAGS} fill="#5f6d66" />

      {/* The peak: plume, mass, shade, ridges, crater and lava. */}
      <path
        d="M1004 206 C990 170 1010 140 990 100 C980 76 1000 50 1030 30 L1060 34 C1036 56 1030 80 1040 104 C1052 136 1030 170 1016 206 Z"
        fill="#56676a"
        opacity="0.5"
      />
      <path d={PEAK} fill={url('peak')} />
      <path d={PEAK_SHADE} fill="#43514e" opacity="0.85" />
      {PEAK_RIDGES.map((d, i) => (
        <path key={i} d={d} stroke="#6d7c74" strokeWidth="3" fill="none" strokeLinecap="round" />
      ))}
      <ellipse cx="1004" cy="210" rx="26" ry="16" fill={url('crater')} className="ember" />
      {LAVA.map((d, i) => (
        <path key={i} d={d} stroke="#d4532e" strokeWidth="2.6" fill="none" strokeLinecap="round" />
      ))}

      <path d={CRAGS} fill="#4f5e58" />
      <path d={HILLS} fill="#6a7a66" />
      {FAR_WOODS.map((tree, i) => (
        <circle
          key={i}
          cx={tree.x}
          cy={tree.y}
          r={tree.r}
          fill={['#4f5f53', '#56665a', '#4a594f'][tree.tone]}
        />
      ))}
      <Arch />

      <rect x="-400" y="480" width="3200" height="920" fill={url('plain')} />
      {FOLDS.map((fold, i) => (
        <g key={i}>
          <path d={fold.d} fill={fold.fill} />
          <path d={fold.crest} stroke="#8a9a7e" strokeWidth="1.6" fill="none" opacity="0.5" />
        </g>
      ))}
      {PATCHES.map((patch, i) => (
        <ellipse
          key={i}
          cx={patch.x}
          cy={patch.y}
          rx={patch.rx}
          ry={patch.ry}
          fill={patch.light ? '#76846c' : '#53655b'}
          opacity="0.7"
        />
      ))}

      {/* Western cliffs and the falls. */}
      <path d={NEAR_HILLS} fill="#5f7064" />
      <path d="M392 500 L404 436 L452 430 L470 506 L458 528 L400 530 Z" fill="#4b5a52" />
      <path d="M424 436 L440 436 L446 520 L420 522 Z" fill="#b9c7bf" opacity="0.85" />
      <ellipse cx="434" cy="524" rx="24" ry="6" fill="#d7e2dc" opacity="0.6" />

      <path d={STREAM} fill={url('gold')} opacity="0.85" />
      <path d={RIVER} fill={url('gold')} data-part="river" />
      <path
        d="M1650 520 C1660 540 1700 560 1760 578 M1900 640 C2000 656 2120 666 2260 680"
        stroke="#f2d189"
        strokeWidth="2"
        fill="none"
        opacity="0.8"
      />
      <path d={POOL} fill={url('gold')} data-part="pool" />
      <path
        d="M940 676 C1000 668 1080 666 1160 674 M960 698 C1020 704 1100 704 1170 696"
        stroke="#f2d189"
        strokeWidth="2.2"
        fill="none"
        opacity="0.75"
      />

      {MID_WOODS.map((tree, i) => (
        <circle
          key={i}
          cx={tree.x}
          cy={tree.y}
          r={tree.r}
          fill={['#4a5b50', '#526459', '#43534a'][tree.tone]}
        />
      ))}
      <Tower glow={url('beacon')} />
      {NEAR_WOODS.map((tree, i) => (
        <circle
          key={i}
          cx={tree.x}
          cy={tree.y}
          r={tree.r}
          fill={['#435449', '#4b5d51', '#3d4d44'][tree.tone]}
        />
      ))}
    </g>
  );
});
```

### scope-view/art.tsx

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

import { memo } from 'react';

/*
 * The tablet around the scope, drawn in SVG in the screen's own units (1024 × 576 wide, 480 ×
 * 600 narrow): the navy surface with its faint range rings, the two black bars with their bronze
 * bands, the four stepped bronze corner brackets with a cyan gem in each boss, and the blue light
 * the bars and brackets throw on the screen. Also the small glyphs: the controller faces of the
 * hints, the pin stamps of the palette, the pin markers in the view, and the minimap's ground.
 */

export type Layout = {
  name: 'wide' | 'narrow';
  /** The screen, in its own units. */
  w: number;
  h: number;
  /** Height of the top and bottom bars. */
  bar: number;
  lens: { x: number; y: number; r: number };
  mini: { x: number; y: number; r: number };
  palette: { y: number };
  gauge: { x: number; y0: number; y1: number };
  hints: { right: number };
  corner: number;
};

export const LAYOUTS: Record<Layout['name'], Layout> = {
  wide: {
    name: 'wide',
    w: 1024,
    h: 576,
    bar: 45,
    lens: { x: 512, y: 288, r: 356 },
    mini: { x: 921, y: 472, r: 62 },
    palette: { y: 485 },
    gauge: { x: 88, y0: 176, y1: 404 },
    hints: { right: 862 },
    corner: 1,
  },
  narrow: {
    name: 'narrow',
    w: 480,
    h: 600,
    bar: 42,
    lens: { x: 240, y: 300, r: 200 },
    mini: { x: 418, y: 524, r: 44 },
    palette: { y: 472 },
    gauge: { x: 22, y0: 156, y1: 252 },
    hints: { right: 370 },
    corner: 0.72,
  },
};

function random(seed: number) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const GRAIN = (() => {
  const rand = random(7);
  return Array.from({ length: 22 }, () => ({
    x: Math.round(rand() * 320) / 10,
    y: Math.round(rand() * 320) / 10,
    o: Math.round((0.06 + rand() * 0.16) * 100) / 100,
  }));
})();

/** The stepped bracket of the top-left corner; the others are its mirror images. */
const BRACKET =
  'M0 0 H186 L200 12 L186 24 H158 L144 38 H82 L68 52 H52 V68 L38 82 V144 L24 158 V186 L12 200 L0 186 Z';
const BRACKET_GROOVE = 'M18 10 H170 M10 18 V170 M60 31 H136 M31 60 V136';
const OCTAGON = (cx: number, cy: number, r: number) => {
  const k = r * 0.414;
  return `M${cx - k} ${cy - r} H${cx + k} L${cx + r} ${cy - k} V${cy + k} L${cx + k} ${cy + r} H${cx - k} L${cx - r} ${cy + k} V${cy - k} Z`;
};

function Corner({ id }: { id: string }) {
  return (
    <>
      <path d={BRACKET} fill="#2a7cff" opacity="0.9" filter={`url(#${id}-soft)`} />
      <path d={BRACKET} fill={`url(#${id}-bronze)`} stroke="#2e2210" strokeWidth="1.6" />
      <path d={BRACKET} fill={`url(#${id}-pits)`} />
      <path
        d={BRACKET}
        fill="none"
        stroke="#f0d9a4"
        strokeWidth="1"
        opacity="0.4"
        transform="translate(1 1)"
      />
      <path d={BRACKET_GROOVE} stroke="#3a2b12" strokeWidth="2.6" strokeLinecap="round" />
      <path
        d={BRACKET_GROOVE}
        stroke="#e5c98c"
        strokeWidth="0.8"
        strokeLinecap="round"
        opacity="0.45"
        transform="translate(1 1.4)"
      />
      {[
        [112, 17],
        [17, 112],
        [154, 17],
        [17, 154],
      ].map(([x, y]) => (
        <path
          key={`${x}-${y}`}
          d={`M${x} ${y - 5} L${x + 5} ${y} L${x} ${y + 5} L${x - 5} ${y} Z`}
          fill="#3a2b12"
        />
      ))}
      <path
        d={OCTAGON(30, 30, 23)}
        fill={`url(#${id}-bronze)`}
        stroke="#2e2210"
        strokeWidth="1.8"
      />
      <path d={OCTAGON(30, 30, 14.5)} fill="#101116" stroke="#7d6235" strokeWidth="1.4" />
      <path d="M30 20.5 L39.5 30 L30 39.5 L20.5 30 Z" fill="#3cd3fc" filter={`url(#${id}-gem)`} />
      <path d="M30 24 L36 30 L30 36 L24 30 Z" fill="#c9f6ff" />
    </>
  );
}

/** One bar's bronze band: two rails and a stepped run between them, with studs. */
function Band({ id, from, to, y }: { id: string; from: number; to: number; y: number }) {
  let d = '';
  let x = from;
  let up = true;
  let i = 0;
  while (x < to - 16) {
    const step = i % 3 === 2 ? 22 : 12;
    d += `${i ? 'L' : 'M'}${x} ${y + (up ? 11 : 5)} L${x} ${y + (up ? 5 : 11)} L${x + step} ${y + (up ? 5 : 11)} `;
    x += step;
    up = !up;
    i++;
  }
  const studs: number[] = [];
  for (let s = from + 60; s < to - 40; s += 120) studs.push(s);
  return (
    <g>
      <rect x={from} y={y} width={to - from} height="2.6" rx="1.3" fill={`url(#${id}-bronze)`} />
      <rect
        x={from}
        y={y + 13.4}
        width={to - from}
        height="2.6"
        rx="1.3"
        fill={`url(#${id}-bronze)`}
      />
      <path
        d={d}
        stroke={`url(#${id}-bronze)`}
        strokeWidth="2.6"
        fill="none"
        strokeLinejoin="bevel"
      />
      {studs.map((s) => (
        <g key={s}>
          <rect x={s - 7} y={y + 2} width="14" height="12" fill="#151515" />
          <path
            d={`M${s} ${y + 2.5} L${s + 5.5} ${y + 8} L${s} ${y + 13.5} L${s - 5.5} ${y + 8} Z`}
            fill={`url(#${id}-bronze)`}
          />
        </g>
      ))}
    </g>
  );
}

/** The surface under everything: navy, faint range rings round the lens, and a dot grid. */
export const Backdrop = memo(function Backdrop({ id, layout }: { id: string; layout: Layout }) {
  const { w, h, lens } = layout;
  const rings: number[] = [];
  for (let r = lens.r + 22; r < Math.hypot(w, h); r += 26) rings.push(r);
  return (
    <svg viewBox={`0 0 ${w} ${h}`} aria-hidden="true" data-part="surface">
      <defs>
        <linearGradient id={`${id}-navy`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#1c1e35" />
          <stop offset="0.7" stopColor="#1a1f3d" />
          <stop offset="1" stopColor="#182449" />
        </linearGradient>
        <pattern id={`${id}-dots`} width="24" height="24" patternUnits="userSpaceOnUse">
          <circle cx="12" cy="12" r="1" fill="#30365e" />
        </pattern>
      </defs>
      <rect width={w} height={h} fill={`url(#${id}-navy)`} />
      <rect width={w} height={h} fill={`url(#${id}-dots)`} opacity="0.7" />
      <g fill="none" stroke="#2a3058" strokeWidth="1.2">
        {rings.map((r, i) => (
          <circle
            key={r}
            cx={lens.x}
            cy={lens.y}
            r={r}
            strokeDasharray={i % 2 ? '2 10' : undefined}
            opacity={Math.max(0.25, 0.85 - i * 0.07)}
          />
        ))}
      </g>
    </svg>
  );
});

/** The bars, bands, brackets and their blue light, over the lens. */
export const Bezel = memo(function Bezel({ id, layout }: { id: string; layout: Layout }) {
  const { w, h, bar, corner } = layout;
  const edge = 200 * corner;
  return (
    <svg viewBox={`0 0 ${w} ${h}`} aria-hidden="true" data-part="bezel">
      <defs>
        <linearGradient id={`${id}-bronze`} x1="0" y1="0" x2="0.3" y2="1">
          <stop offset="0" stopColor="#cfae6e" />
          <stop offset="0.4" stopColor="#9c7b45" />
          <stop offset="1" stopColor="#5a4321" />
        </linearGradient>
        <pattern id={`${id}-pits`} width="9" height="9" patternUnits="userSpaceOnUse">
          <circle cx="2" cy="3" r="0.8" fill="#2e2210" opacity="0.35" />
          <circle cx="6.5" cy="7" r="0.6" fill="#2e2210" opacity="0.3" />
          <circle cx="7" cy="1.5" r="0.5" fill="#fff3d0" opacity="0.25" />
        </pattern>
        <pattern id={`${id}-grain`} width="32" height="32" patternUnits="userSpaceOnUse">
          {GRAIN.map((dot, i) => (
            <rect
              key={i}
              x={dot.x}
              y={dot.y}
              width="0.9"
              height="0.9"
              fill="#fff"
              opacity={dot.o}
            />
          ))}
        </pattern>
        <linearGradient id={`${id}-spill-down`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#1d64d8" stopOpacity="0.5" />
          <stop offset="1" stopColor="#1d64d8" stopOpacity="0" />
        </linearGradient>
        <linearGradient id={`${id}-spill-up`} x1="0" y1="1" x2="0" y2="0">
          <stop offset="0" stopColor="#1d64d8" stopOpacity="0.85" />
          <stop offset="1" stopColor="#1d64d8" stopOpacity="0" />
        </linearGradient>
        <filter id={`${id}-soft`} x="-30%" y="-30%" width="160%" height="160%">
          <feGaussianBlur stdDeviation="9" />
        </filter>
        <filter id={`${id}-gem`} x="-60%" y="-60%" width="220%" height="220%">
          <feGaussianBlur stdDeviation="2.2" result="b" />
          <feMerge>
            <feMergeNode in="b" />
            <feMergeNode in="SourceGraphic" />
          </feMerge>
        </filter>
      </defs>

      {/* Light thrown on the screen by the bars. */}
      <rect x="0" y={bar} width={w} height="16" fill={`url(#${id}-spill-down)`} />
      <rect x="0" y={h - bar - 34} width={w} height="34" fill={`url(#${id}-spill-up)`} />

      {/* The bars. */}
      <rect x="0" y="0" width={w} height={bar} fill="#151515" />
      <rect x="0" y={h - bar} width={w} height={bar} fill="#181919" />
      <rect x="0" y="0" width={w} height={bar} fill={`url(#${id}-grain)`} />
      <rect x="0" y={h - bar} width={w} height={bar} fill={`url(#${id}-grain)`} />
      <rect x="0" y={bar - 2} width={w} height="2" fill="#0d4f9c" />
      <rect x="0" y={h - bar} width={w} height="2" fill="#0d4f9c" />
      <Band id={id} from={edge - 6} to={w - edge + 6} y={3} />
      <Band id={id} from={edge - 6} to={w - edge + 6} y={h - 19} />

      {/* The corners. */}
      <g transform={`scale(${corner})`}>
        <Corner id={id} />
      </g>
      <g transform={`translate(${w} 0) scale(${-corner} ${corner})`}>
        <Corner id={id} />
      </g>
      <g transform={`translate(0 ${h}) scale(${corner} ${-corner})`}>
        <Corner id={id} />
      </g>
      <g transform={`translate(${w} ${h}) scale(${-corner} ${-corner})`}>
        <Corner id={id} />
      </g>
    </svg>
  );
});

/** A controller face button: a filled disc with its letter, or a minus. */
export function FaceGlyph({ letter }: { letter: string }) {
  return (
    <svg viewBox="0 0 32 32" aria-hidden="true">
      <circle cx="16" cy="16" r="16" fill="currentColor" />
      {letter === '-' ? (
        <rect x="8.5" y="14" width="15" height="4" rx="1.4" fill="var(--ink)" />
      ) : (
        <text x="16" y="22.4" textAnchor="middle" fontSize="18" fontWeight="700" fill="var(--ink)">
          {letter}
        </text>
      )}
    </svg>
  );
}

/** The mark inside each pin stamp: one per colour, so colour is never the only cue. */
const MARKS = [
  // ring
  <circle key="0" cx="12" cy="12" r="4.2" fill="none" strokeWidth="2.4" />,
  // triangle
  <path
    key="1"
    d="M12 7.4 L16.6 15.6 H7.4 Z"
    fill="none"
    strokeWidth="2.2"
    strokeLinejoin="round"
  />,
  // two bars
  <path
    key="2"
    d="M7.5 9.5 H16.5 M7.5 14.5 H16.5"
    fill="none"
    strokeWidth="2.4"
    strokeLinecap="round"
  />,
  // corner
  <path
    key="3"
    d="M8 7.5 V16 H16.5"
    fill="none"
    strokeWidth="2.4"
    strokeLinecap="round"
    strokeLinejoin="round"
  />,
  // cross
  <path
    key="4"
    d="M8.4 8.4 L15.6 15.6 M15.6 8.4 L8.4 15.6"
    fill="none"
    strokeWidth="2.4"
    strokeLinecap="round"
  />,
  // diamond
  <path
    key="5"
    d="M12 7 L17 12 L12 17 L7 12 Z"
    fill="none"
    strokeWidth="2.2"
    strokeLinejoin="round"
  />,
];

export const markCount = MARKS.length;

/** A pin stamp of the palette: a rounded square in the pin's colour with its mark. */
export function PinStamp({ mark }: { mark: number }) {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true">
      <rect x="1" y="1" width="22" height="22" rx="5" fill="currentColor" />
      <g stroke="var(--stamp-ink)">{MARKS[mark % MARKS.length]}</g>
    </svg>
  );
}

/** A pin standing in the view: a teardrop on a short stem, tip at (0, 0). */
export function PinMarker({ color, mark }: { color: string; mark: number }) {
  return (
    <g>
      <ellipse cx="0" cy="0" rx="6" ry="2" fill="#000" opacity="0.35" />
      <path d="M0 0 L0 -10" stroke={color} strokeWidth="2" />
      <path
        d="M0 -10 C-3 -15 -11 -19 -11 -27 A11 11 0 0 1 11 -27 C11 -19 3 -15 0 -10 Z"
        fill={color}
        stroke="#0d1426"
        strokeWidth="1.6"
      />
      <g transform="translate(-7.2 -34.2) scale(0.6)" stroke="#0d1426">
        {MARKS[mark % MARKS.length]}
      </g>
    </g>
  );
}

/** The minimap's ground, north up, in a radius of 1 (scaled by its caller). */
export const MiniGround = memo(function MiniGround({ id }: { id: string }) {
  return (
    <g>
      <defs>
        <radialGradient id={`${id}-ground`} cx="0.45" cy="0.4" r="0.7">
          <stop offset="0" stopColor="#7b6f37" />
          <stop offset="1" stopColor="#4e4521" />
        </radialGradient>
      </defs>
      <circle r="1" fill={`url(#${id}-ground)`} />
      {/* Rough high ground, the lake, the river, the old border. */}
      <path
        d="M-0.62 -0.62 C-0.3 -0.8 0.2 -0.78 0.5 -0.56 C0.62 -0.42 0.48 -0.24 0.28 -0.2 C0.06 -0.16 -0.1 -0.3 -0.3 -0.26 C-0.5 -0.22 -0.72 -0.4 -0.62 -0.62 Z"
        fill="#3f3818"
        opacity="0.85"
      />
      <path
        d="M-1 0.02 C-0.7 -0.1 -0.4 0.02 -0.18 0.12 C0.02 0.22 0.1 0.38 0.02 0.56 C-0.04 0.7 -0.2 0.62 -0.3 0.48 C-0.44 0.3 -0.7 0.42 -0.86 0.36 C-0.96 0.32 -1 0.2 -1 0.02 Z"
        fill="#3c4553"
      />
      <path
        d="M0.36 0.2 C0.5 0.3 0.62 0.24 0.74 0.32 C0.84 0.4 0.9 0.5 1 0.52"
        stroke="#3c4553"
        strokeWidth="0.07"
        fill="none"
      />
      <g fill="none" stroke="#8d8146" strokeWidth="0.02" opacity="0.6">
        <circle cx="-0.5" cy="0.62" r="0.12" />
        <circle cx="-0.5" cy="0.62" r="0.22" />
        <circle cx="0.46" cy="0.06" r="0.1" />
        <circle cx="0.46" cy="0.06" r="0.2" />
      </g>
      <path
        d="M-0.6 -0.8 C-0.4 -0.7 -0.2 -0.66 0 -0.7 C0.2 -0.74 0.34 -0.86 0.42 -0.92"
        stroke="#ecdc94"
        strokeWidth="0.03"
        fill="none"
      />
    </g>
  );
});
```

### scope-view/scope-view.module.css

```css
/* Scope view (scope-view.tsx): a game tablet's spyglass screen on a plain stage. Every size is a
   multiple of --u, 1 px at full size (a 1024 × 576 screen), less on a narrower stage; under
   640 px the screen takes its 480 × 600 narrow layout, chosen in the component, and --u follows
   that width. The layout's numbers (lens centre and radius, minimap, gauge…) arrive as unitless
   custom properties on the screen and are multiplied by --u here.

   The screen is dark on any page, as a game draws it; only its edge and its shadow on the page,
   and the focus ring, follow the colour scheme, through light-dark() tokens. No filter touches a
   glyph or a word: glows are box- and text-shadows behind the sharp art.

   Motion: the iris is a clip-path transition; the reticle, caret and gauge move by transitions;
   pins dropping and leaving, the ripple, the refusal shake and the idle shimmer are keyframes. */

.root {
  --cyan: #3cd3fc;
  --cyan-glow: rgb(60 211 252 / 0.55);
  --ink: #0e1b31;
  --navy: #1b1d33;
  --rail: rgb(128 160 182 / 0.6);
  --focus-on-screen: #a9f0ff;
  --edge: rgb(0 0 0 / 0.18);
  --shade: rgb(14 18 40 / 0.28);
  --focus: #2f6fdf;
  position: relative;
  container-type: inline-size;
  isolation: isolate;
  display: grid;
  place-items: center;
  width: 100%;
  min-height: 400px;
  padding: 24px 0;
  box-sizing: border-box;
  font-family:
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --edge: light-dark(rgb(0 0 0 / 0.18), rgb(255 255 255 / 0.14));
    --shade: light-dark(rgb(14 18 40 / 0.28), rgb(0 0 0 / 0.55));
    --focus: light-dark(#2f6fdf, #8db4ff);
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

/* The screen: the tablet's whole face. */
.screen {
  --u: min(1px, 100cqi / 1024);
  position: relative;
  width: calc(var(--w) * var(--u));
  height: calc(var(--h) * var(--u));
  overflow: hidden;
  border-radius: calc(10 * var(--u));
  background: var(--navy);
  box-shadow:
    0 0 0 1px var(--edge),
    0 calc(10 * var(--u)) calc(34 * var(--u)) var(--shade);
  color: var(--cyan);
  user-select: none;
  -webkit-user-select: none;
}
.screen[data-layout='narrow'] {
  --u: min(1.2px, 100cqi / 480);
}
.screen svg {
  display: block;
  width: 100%;
  height: 100%;
}
.surface,
.bezel {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

/* The lens: a circle cut out of the surface, opened and shut like an iris. */
.lens {
  position: absolute;
  left: calc((var(--lx) - var(--lr)) * var(--u));
  top: calc((var(--ly) - var(--lr)) * var(--u));
  width: calc(2 * var(--lr) * var(--u));
  height: calc(2 * var(--lr) * var(--u));
  border-radius: 50%;
  clip-path: circle(0% at 50% 50%);
  transition: clip-path 380ms cubic-bezier(0.5, 0, 0.75, 0);
  cursor: grab;
  touch-action: none;
  outline: none;
}
.screen[data-open] .lens {
  clip-path: circle(50% at 50% 50%);
  transition: clip-path 560ms cubic-bezier(0.16, 1, 0.3, 1);
}
.lens:active {
  cursor: grabbing;
}
.screen:not([data-open]) .lens {
  pointer-events: none;
}
/* The keyboard's ring: inside the lens, so the bars never hide it. */
.lens::after {
  content: '';
  position: absolute;
  inset: calc(6 * var(--u));
  border-radius: 50%;
  box-shadow: inset 0 0 0 2px var(--focus-on-screen);
  opacity: 0;
  pointer-events: none;
}
.lens:focus-visible::after {
  opacity: 1;
}
.scan {
  opacity: 0.24;
}
.screen[data-idle] .scan {
  animation: shimmer 3.2s ease-in-out infinite alternate;
}
.screen[data-idle] .lens :global(.beacon) {
  transform-box: fill-box;
  transform-origin: center;
  animation: beacon 1.6s ease-in-out infinite;
}
.screen[data-idle] .lens :global(.ember) {
  animation: ember 2.4s ease-in-out infinite;
}
@keyframes shimmer {
  from {
    opacity: 0.2;
  }
  to {
    opacity: 0.3;
  }
}
@keyframes beacon {
  0%,
  100% {
    opacity: 0.7;
    transform: scale(0.85);
  }
  50% {
    opacity: 1;
    transform: scale(1.25);
  }
}
@keyframes ember {
  0%,
  100% {
    opacity: 0.75;
  }
  40% {
    opacity: 1;
  }
  70% {
    opacity: 0.85;
  }
}

/* Pins standing in the view. */
.pin {
  transform-box: fill-box;
  transform-origin: 50% 100%;
  transition: transform 160ms cubic-bezier(0.3, 1.6, 0.5, 1);
}
.pin[data-aimed] {
  transform: scale(1.14);
}
.pin[data-dropped] {
  animation: drop 520ms cubic-bezier(0.3, 0, 0.3, 1) both;
}
.pin[data-leaving] {
  animation: lift 300ms cubic-bezier(0.5, 0, 0.75, 0) forwards;
}
@keyframes drop {
  0% {
    opacity: 0;
    transform: translateY(-34px) scale(1.3);
  }
  45% {
    opacity: 1;
    transform: translateY(2px) scale(0.92, 1.06);
  }
  70% {
    transform: translateY(0) scale(1.06, 0.94);
  }
  100% {
    transform: none;
  }
}
@keyframes lift {
  to {
    opacity: 0;
    transform: translateY(-30px) scale(0.8);
  }
}

/* The reticle: a centre dot in a disc between two arcs. */
.reticle {
  position: absolute;
  left: calc(var(--lx) * var(--u));
  top: calc(var(--ly) * var(--u));
  width: calc(72 * var(--u));
  height: calc(40 * var(--u));
  translate: -50% -50%;
  pointer-events: none;
  opacity: 0;
  transition: opacity 160ms;
}
.screen[data-open] .reticle {
  opacity: 1;
  transition: opacity 240ms 260ms;
}
.arcLeft,
.arcRight {
  fill: none;
  stroke: rgb(218 226 222 / 0.62);
  stroke-width: 1.5;
  stroke-linecap: round;
  transition:
    transform 260ms cubic-bezier(0.3, 1.5, 0.5, 1),
    stroke 160ms;
}
.screen:not([data-open]) .arcLeft {
  transform: translateX(-12px);
}
.screen:not([data-open]) .arcRight {
  transform: translateX(12px);
}
.screen[data-open] .arcLeft,
.screen[data-open] .arcRight {
  transition:
    transform 420ms 300ms cubic-bezier(0.3, 1.5, 0.5, 1),
    stroke 160ms;
}
.screen[data-open] .reticle[data-zooming] .arcLeft {
  transform: translateX(5px);
  transition-delay: 0ms;
}
.screen[data-open] .reticle[data-zooming] .arcRight {
  transform: translateX(-5px);
  transition-delay: 0ms;
}
.disc {
  fill: rgb(128 162 186 / 0.82);
  transition: fill 160ms;
}
.dot {
  fill: #d4705a;
  transform-box: fill-box;
  transform-origin: center;
  transition:
    fill 160ms,
    transform 200ms cubic-bezier(0.3, 1.6, 0.5, 1);
}
.reticle[data-aimed] .arcLeft,
.reticle[data-aimed] .arcRight {
  stroke: var(--cyan);
}
.reticle[data-aimed] .disc {
  fill: rgb(60 211 252 / 0.4);
}
.reticle[data-aimed] .dot {
  fill: #e9fbff;
  transform: scale(1.3);
}
.ripple {
  position: absolute;
  left: 50%;
  top: 50%;
  width: calc(28 * var(--u));
  height: calc(28 * var(--u));
  margin: calc(-14 * var(--u)) 0 0 calc(-14 * var(--u));
  border-radius: 50%;
  box-shadow:
    0 0 0 calc(2 * var(--u)) var(--cyan),
    0 0 calc(10 * var(--u)) var(--cyan-glow);
  opacity: 0;
  animation: ripple 620ms cubic-bezier(0.2, 0.8, 0.3, 1);
}
@keyframes ripple {
  0% {
    opacity: 0.95;
    transform: scale(0.3);
  }
  100% {
    opacity: 0;
    transform: scale(3.2);
  }
}

/* The title in the top bar. */
.title {
  position: absolute;
  left: 50%;
  top: calc((var(--bar) / 2 + 9) * var(--u));
  translate: -50% -50%;
  margin: 0;
  font-size: calc(17.5 * var(--u));
  font-weight: 500;
  line-height: 1;
  letter-spacing: 0.005em;
  color: var(--cyan);
  text-shadow: 0 0 calc(6 * var(--u)) var(--cyan-glow);
  white-space: nowrap;
  pointer-events: none;
}

/* The zoom gauge: two rails with arrow caps, a lit fill and a thumb. */
.gauge {
  position: absolute;
  left: calc(var(--gx) * var(--u));
  top: calc(var(--gy0) * var(--u));
  width: calc(16 * var(--u));
  height: calc((var(--gy1) - var(--gy0)) * var(--u));
  translate: -50% 0;
  border-radius: calc(4 * var(--u));
  cursor: ns-resize;
  touch-action: none;
  outline: none;
  transition: opacity 240ms;
}
.screen:not([data-open]) .gauge {
  opacity: 0.3;
  pointer-events: none;
}
.gauge:focus-visible {
  box-shadow:
    0 0 0 calc(4 * var(--u)) var(--navy),
    0 0 0 calc(4 * var(--u) + 2px) var(--focus-on-screen);
}
.rails {
  position: absolute;
  inset: 0;
  fill: var(--rail);
}
.gauge .cap {
  position: absolute;
  left: 0;
  width: 100%;
  height: calc(8 * var(--u));
  fill: var(--rail);
}
.gauge .cap[data-end='top'] {
  top: calc(-4 * var(--u));
}
.gauge .cap[data-end='bottom'] {
  bottom: calc(-4 * var(--u));
  rotate: 180deg;
}
.fill {
  position: absolute;
  left: calc(5.6 * var(--u));
  width: calc(4.8 * var(--u));
  bottom: calc(6 * var(--u));
  height: calc(var(--gauge-t) * ((var(--gy1) - var(--gy0) - 12) * var(--u)));
  background: linear-gradient(to top, rgb(60 211 252 / 0.15), rgb(60 211 252 / 0.5));
}
.thumb {
  position: absolute;
  left: 50%;
  bottom: calc((var(--gauge-t) * (var(--gy1) - var(--gy0) - 12) + 6) * var(--u));
  width: calc(22 * var(--u));
  height: calc(4 * var(--u));
  translate: -50% 50%;
  border-radius: calc(2 * var(--u));
  background: var(--cyan);
  box-shadow: 0 0 calc(8 * var(--u)) var(--cyan-glow);
}
.readout {
  position: absolute;
  left: 50%;
  top: calc(100% + 12 * var(--u));
  translate: -50% 0;
  font-size: calc(11 * var(--u));
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--cyan);
  opacity: 0.85;
  white-space: nowrap;
  text-shadow: 0 0 calc(5 * var(--u)) var(--cyan-glow);
}

/* The pin palette: one stamp per colour, a caret under the selected one. */
.palette {
  position: absolute;
  left: calc(var(--lx) * var(--u));
  top: calc(var(--py) * var(--u));
  display: flex;
  gap: calc(6 * var(--u));
  translate: -50% -50%;
  transition:
    opacity 200ms,
    visibility 0s 200ms;
}
.screen:not([data-open]) .palette {
  opacity: 0;
  visibility: hidden;
}
.screen[data-open] .palette {
  transition: opacity 240ms 320ms;
}
.stamp {
  --stamp-ink: color-mix(in oklab, var(--pin) 58%, #000);
  display: block;
  width: calc(18 * var(--u));
  height: calc(18 * var(--u));
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: calc(4 * var(--u));
  background: none;
  color: var(--pin);
  box-shadow: 0 0 calc(7 * var(--u)) color-mix(in srgb, var(--pin) 65%, transparent);
  cursor: pointer;
  transition:
    color 200ms,
    box-shadow 200ms,
    translate 160ms;
}
.stamp[data-used] {
  --stamp-ink: rgb(30 38 36 / 0.85);
  color: rgb(60 72 66 / 0.82);
  box-shadow: none;
  cursor: default;
}
.stamp:not([data-used]):hover {
  translate: 0 calc(-1.5 * var(--u));
}
.stamp:focus-visible {
  outline: 2px solid var(--focus-on-screen);
  outline-offset: 2px;
}
.caret {
  position: absolute;
  left: calc((var(--sel) * 24 + 9) * var(--u));
  top: calc(100% + 5 * var(--u));
  width: calc(9 * var(--u));
  height: calc(5 * var(--u));
  translate: -50% 0;
  background: var(--cyan);
  clip-path: polygon(50% 0, 100% 100%, 0 100%);
  transition:
    left 240ms cubic-bezier(0.3, 1.5, 0.5, 1),
    opacity 160ms;
}
.caret[data-hidden] {
  opacity: 0;
}

/* The minimap in the corner. */
.mini {
  position: absolute;
  left: calc((var(--mx) - var(--mr) * 1.16) * var(--u));
  top: calc((var(--my) - var(--mr) * 1.16) * var(--u));
  width: calc(var(--mr) * 2.32 * var(--u));
  height: calc(var(--mr) * 2.32 * var(--u));
  pointer-events: none;
}
.miniPin {
  transform-box: fill-box;
  transform-origin: center;
  animation: pop 420ms cubic-bezier(0.3, 1.8, 0.5, 1) both;
}
@keyframes pop {
  from {
    transform: scale(0);
  }
}

/* The button hints in the bottom bar. */
.hints {
  position: absolute;
  right: calc((var(--w) - var(--hr)) * var(--u));
  top: calc((var(--h) - var(--bar) + 7) * var(--u));
  display: flex;
  align-items: center;
  gap: calc(22 * var(--u));
  translate: 0 -50%;
}
.hint {
  display: inline-flex;
  align-items: center;
  gap: calc(6 * var(--u));
  margin: 0;
  padding: calc(1 * var(--u)) calc(3 * var(--u));
  border: 0;
  border-radius: calc(4 * var(--u));
  background: none;
  color: var(--cyan);
  font: inherit;
  font-size: calc(13.2 * var(--u));
  font-weight: 600;
  line-height: 1.2;
  white-space: nowrap;
  text-shadow: 0 0 calc(6 * var(--u)) var(--cyan-glow);
  cursor: pointer;
}
.hint svg {
  width: calc(15 * var(--u));
  height: calc(15 * var(--u));
  border-radius: 50%;
  box-shadow: 0 0 calc(6 * var(--u)) var(--cyan-glow);
}
.hint:hover span {
  color: #b7f1ff;
}
.hint[aria-disabled='true'] {
  opacity: 0.45;
}
.hint:focus-visible {
  outline: 2px solid var(--focus-on-screen);
  outline-offset: 1px;
}

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

@media (prefers-reduced-motion: reduce) {
  .lens,
  .screen[data-open] .lens,
  .reticle,
  .screen[data-open] .reticle,
  .arcLeft,
  .arcRight,
  .screen[data-open] .arcLeft,
  .screen[data-open] .arcRight,
  .pin,
  .palette,
  .screen[data-open] .palette,
  .caret,
  .gauge,
  .stamp {
    transition: none;
  }
  .pin[data-dropped],
  .pin[data-leaving],
  .ripple,
  .miniPin,
  .screen[data-idle] .scan {
    animation: none;
  }
  .ripple {
    display: none;
  }
}
```

### scope-view/use-autoplay.ts

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

'use client';

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

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input. `visible` and `reduced` are returned too, so
 * idle loops can pause offscreen and motion can be skipped.
 */
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 };
}
```