# Moon dragon

> A night scene in code that tells a looping story: a dragon rises from the water to meet a swordsman under a full moon.

A full moon on still water, a swordsman in a straw hat kneeling in a crescent boat. The moon swells and glows; a dark shape glides under the water towards the boat; a black dragon breaks the surface and rises, water streaming off its long body, to hang its head over the moon and look at him. He stands, bows and holds out his sword, the violet petals turn white in a wave, and the dragon bows its head in answer before sinking back. Tap the moon or press Summon to call it, or step through the chapters. All of it is vector, drawn in SVG and CSS.

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

Category: Motion.

## Anatomy

1. **Moon**: A full moon with clipped craters and mist; it swells, its halo breathes and rings leave it on the Moon beat.
2. **Dragon body**: One path, rebuilt every frame on a spine that follows the dragon’s path, with waves running down it; blurred and dark under the water.
3. **Head**: A rigid drawing riding the spine’s first point: it leads the rise, turns to the swordsman, nods, and leans towards the pointer.
4. **Swordsman**: A jointed rig: robe, torso, head and hat, two-bone arms solved to the sword’s hold. He stands, bows and offers the sword.
5. **Lantern**: A small yellow light at the stern, with a soft glow that flickers.
6. **Play button**: Plays or pauses the story; `aria-pressed` and its label say which.
7. **Chapters**: Five buttons in a toolbar (Moon, Approach, Rise, Bow, Blossom), each with its beat’s progress; the arrow keys move between them.
8. **Summon**: Calls the dragon up from the water, like a tap on the moon.

## When to use

- A hero or splash screen that tells a short wordless story before any text: a game, a book, a launch page.
- An empty or waiting screen with room to breathe, where a slow loop keeps the page alive without asking for anything.
- A themed moment the reader can play with: tap the moon, step through the chapters, lean the dragon with the pointer.
- A scene that has to stay sharp at any size: every piece is vector, scaled by the stage’s width.

## When not to use

- Behind text that has to be read: the moon, the dragon and the petals fight the words. Use a flat or blurred backdrop.
- A small thumbnail or an icon: under about 280 px the swordsman and the chapter bar turn to specks. Use a still illustration.
- A dense working screen (a dashboard, a form): the story pulls the eye away. Use a static header image.
- Several scenes on one screen at once: each runs its own story clock. Show one live, the rest as `autoplay={false}` tableaux.
- Telling the reader something happened in the app: use a toast or an inline message; the beats are the story’s, not the app’s.

## Installation

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

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

export function NightHero() {
  return (
    <MoonDragon
      speed={0.8}
      startAt="moon"
      onBeat={(beat) => console.log(beat)}
    />
  );
}
```

## States

- **Moon**: `startAt="moon"`: the moon swells and glows, a halo ring leaves it, light flares on the water.
- **Approach**: `startAt="approach"`: a dark shape glides under the surface towards the boat, rings spreading over it.
- **Rise**: `startAt="rise"`: the dragon has risen and hangs its head over the moon, looking at the swordsman. Also the tableau with autoplay off.
- **Bow**: `startAt="bow"`: the swordsman stands, bows, and holds his sword out level on both palms.
- **Blossom**: `startAt="blossom"`: the petals are white, the dragon bows its head and its sparks multiply. Reduced motion opens here.
- **Ember palette**: `palette="ember"`, on Blossom: a warm dusk, the same story.
- **Tide palette**: `palette="tide"`, on Rise: a sea-green night.
- **No petals**: `petals={0}`: only the clouds, the water and the dragon move.
- **Light frame**: The picture is always a night; only the edge line and the buttons follow `colorScheme="light"`.
- **Dark frame**: `colorScheme="dark"`: a pale edge and dark buttons.

## How it works

- The story is a set of pure functions of one clock, in seconds from the start of a 39 s loop: Moon (0–4.5), Approach (4.5–11), Rise (11–18.5), Bow (18.5–23.5), Blossom (23.5–30.5) and Return (30.5–39). Every frame can be drawn from the time alone, so a chapter button jumps straight to its beat, and the loop’s last frame is its first. A second, ambient clock keeps the living motion going (the body’s waves, the breath, the moon’s pulse) while the story holds.
- The dragon is skinned on a spine that follows a path. Every point of its outline (the drawn crest, shoulder and foreleg, then a long body by rule, its spikes and girth thinning to the tail) is stored once as a distance along the spine and an offset from it. The head travels the path: under the water beneath the boat, up through the surface by the bow, over to the drawing’s own neck line, and on down into the water; each point of the body sits on the path behind it, with two waves running down it, so it swims, rises and settles as one serpent.
- While it rises the top of its path is pressed down and drawn in from the right edge, so the head stays in the picture; as it settles the body lifts into the drawing’s pose. The head is a rigid drawing on the spine’s first point: it leads the rise snout first, turns to the swordsman, nods in answer, and leans towards the pointer.
- Under the water the same body and head are drawn blurred, dark and foreshortened, clipped below the surface; above it they are clipped at the surface. Where the body crosses the water line, rings spread; before it breaks through, the water swells; then come a splash, streams falling off the body and bubbles over the gliding head. These marks are drawn from the clock with fixed random numbers, so they scrub like the rest.
- The swordsman is a jointed rig: hips carrying a robe down to the deck, a torso that leans about the hips, a head and hat that tilt about the neck, two two-bone arms solved each frame (inverse kinematics) so the hands land where the sword wants them, and the sword, placed between an upright salute, level before the chest, and level above the head.
- On Blossom each petal’s white copy and a soft glow fade in over the violet, delayed by its distance from the dragon’s head, so a wave of white spreads through the air; the petals’ CSS animations slow to half speed through their playback rate, without a jump.
- Everything else moves on the compositor, by transform and opacity: the clouds drift, the reflection shimmers, the boat bobs, the lantern flickers, the petals fall, sway and tumble; the moon’s swell, its halo, the rings leaving it and the light on the water are set each frame by transform and opacity.
- The pointer slides each layer by its depth (parallax) and eases the risen dragon’s head towards it. Offscreen or in a hidden tab everything pauses. A chapter plays its beat and holds at its end; after ten seconds without input the story plays on, if it autoplays.

## API reference

- **autoplay** (`boolean`, default `true`): The story plays on its own while on screen. Off, it holds the startAt beat’s tableau (Rise by default) until the reader presses play, Summon or a chapter.
- **speed** (`number`, default `1`): How fast everything moves: 1 is the story’s own pace (a 39 s loop), 0.5 half as fast.
- **startAt** (`'moon' | 'approach' | 'rise' | 'bow' | 'blossom'`): The beat it opens on: playing, from the beat’s start; held, on its tableau. Without it the story starts at Moon (or, held, shows Rise).
- **onBeat** (`(beat: 'moon' | 'approach' | 'rise' | 'bow' | 'blossom' | 'return') => void`): Called with each beat as the story reaches it, or as the reader jumps to it.
- **loop** (`boolean`, default `true`): Starts over after the dragon sinks back. Off, it plays once and holds on the Blossom tableau, still alive.
- **petals** (`number`, default `26`): How many petals fall, up to 80. The drawing has 26; more are scattered the same way, with a fixed seed.
- **palette** (`'violet' | 'ember' | 'tide'`, default `'violet'`): The night’s colours: violet, a warm ember dusk, or a sea-green tide.
- **followPointer** (`boolean`, default `true`): The layers shift a little with the pointer, and the risen dragon’s head follows it.
- **label** (`string`): What the picture shows, for screen readers.
- **colorScheme** (`'light' | 'dark' | 'system'`): The frame’s and the buttons’ colours: light, dark, or the reader’s system setting. The picture itself is always a night. Without it the component follows the page’s color-scheme.
- **className** (`string`): Extra class for the root.

## Accessibility

- The scene is one image (role="img") with a label describing it; every drawn piece inside is hidden from assistive technology.
- Under the picture sit real buttons: play or pause (aria-pressed, labelled Play the story or Pause the story), a toolbar of five chapter buttons with the current one marked aria-current="step", and Summon. All have a visible focus ring.
- In the chapter toolbar one button takes Tab; the arrow keys, Home and End move between chapters and jump to them. Tapping the moon does what Summon does; it is a pointer shortcut, so it is left out of the tab order and the accessibility tree.
- When the reader jumps to a chapter or summons the dragon, a polite live region reads out what the beat shows; the autoplaying story does not announce itself.
- Under prefers-reduced-motion nothing moves: no animations, transitions, parallax or story clock. It opens on the Blossom tableau, with the petals white, and the chapter buttons step through each beat’s tableau; the play button is not shown.
- Any real input (a chapter, Summon, play or pause) hands control to the reader; the story pauses whenever it is offscreen or the tab is hidden.

## Source

### moon-dragon/moon-dragon.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
} from 'react';
import {
  BACK_CLOUDS,
  CRATERS,
  DEPTH,
  EYE,
  EYE_CENTRE,
  FRONT_LEFT,
  FRONT_RIGHT,
  HEAD,
  HORIZON,
  HULL,
  KEEL,
  LANTERN,
  MID_CLOUD,
  MOON,
  SHEATH,
  SPARKS,
  STROKES,
  SURFACE,
  cloudPath,
  petals as scatter,
  polygon,
  pose,
  whiskers,
  type Point,
} from './scene';
import { HAT_SHAPE, HEAD_SHAPE, SWORD, TORSO, rig } from './rig';
import {
  BEATS,
  CHAPTERS,
  RINGS,
  RING_LIFE,
  TOTAL,
  beatAt,
  dragonAt,
  lightsAt,
  stanceAt,
  waterAt,
  type Beat,
} from './story';
import { useAutoplay } from './use-autoplay';
import styles from './moon-dragon.module.css';

/*
 * Moon dragon: a night scene drawn in code that plays a short story on a loop. A full moon sits
 * on still water and a swordsman in a straw hat kneels in a crescent boat. The moon swells and
 * glows (Moon); a dark shape glides under the water towards the boat (Approach); a black dragon
 * breaks the surface and rises, water streaming off it, to hang its head over the moon and look
 * at him (Rise); he stands, bows and holds out his sword on both palms (Bow); the violet petals
 * turn white in a wave and the dragon bows its head in answer (Blossom); then it sinks back, he
 * kneels, the petals turn violet again, and the story starts over (Return).
 *
 * - The story is a set of pure functions of one clock (story.ts), so any moment can be drawn from
 *   the time alone: the reader can jump to a beat, and the loop's last frame is its first. A
 *   second, ambient clock keeps the living motion going (the body's waves, the breath, the
 *   moon's pulse) while the story holds.
 * - The dragon is skinned on a spine that follows a path (scene.ts): the head travels it and every
 *   point of the body sits on it behind the head, with waves running down the body. Under the
 *   water the same shape is drawn blurred and dark; above it, clipped at the surface.
 * - The swordsman is a jointed rig (rig.ts): hips, torso, head and hat, two-bone arms solved to
 *   the sword's hold, and the sword.
 * - The water (rings, the swell, the splash, the streams off the body, bubbles) is drawn from the
 *   clock too, with fixed random numbers, so it scrubs like the rest.
 * - Everything else moves on the compositor (transform and opacity): the clouds, the shimmer,
 *   the boat's bob, the lantern, the petals; the moon's glow, its halo rings and the light on the
 *   water are set each frame by transform and opacity.
 * - The pointer slides the layers by their depth and leans the dragon's head. Offscreen or in a
 *   hidden tab everything pauses; under reduced motion the story is a still step-through that
 *   opens on its last tableau.
 */

export type MoonDragonPalette = 'violet' | 'ember' | 'tide';
export type MoonDragonBeat = Beat;

/** Extra specks of light that come out around the dragon when it answers. */
const BLOOM_SPARKS: { x: number; y: number; r: number }[] = [
  { x: 925, y: 300, r: 2 },
  { x: 905, y: 420, r: 2.5 },
  { x: 930, y: 520, r: 2 },
  { x: 1010, y: 560, r: 3 },
  { x: 1060, y: 250, r: 2 },
  { x: 1185, y: 330, r: 2.5 },
  { x: 1210, y: 400, r: 2 },
  { x: 1250, y: 270, r: 3 },
  { x: 1330, y: 230, r: 2 },
  { x: 1400, y: 300, r: 2.5 },
  { x: 1180, y: 520, r: 2 },
  { x: 880, y: 470, r: 1.5 },
  { x: 1120, y: 600, r: 2 },
  { x: 1460, y: 200, r: 2 },
  { x: 990, y: 210, r: 1.5 },
  { x: 1300, y: 380, r: 1.5 },
];
const RIPPLES = 18;
const STILL = (id: Beat) => BEATS.find((b) => b.id === id)!.still;
const START = (id: Beat) => BEATS.find((b) => b.id === id)!.start;
const BLOSSOM_END = BEATS.find((b) => b.id === 'blossom')!.end;

export function MoonDragon({
  autoplay = true,
  speed = 1,
  startAt,
  onBeat,
  loop = true,
  petals = 26,
  palette = 'violet',
  followPointer = true,
  label = 'A night scene: a swordsman in a boat under a full moon, and a black dragon that rises from the water',
  colorScheme,
  className = '',
}: {
  /** The story plays on its own while on screen; off, it holds the `startAt` beat's tableau. */
  autoplay?: boolean;
  /** How fast everything moves: 1 is the story's own pace (a 39 s loop), 0.5 half as fast. */
  speed?: number;
  /** The beat it opens on: playing, from the beat's start; held, on its tableau. */
  startAt?: Exclude<Beat, 'return'>;
  /** Called with each beat as the story reaches it. */
  onBeat?: (beat: Beat) => void;
  /** Starts over after the dragon sinks back; off, it plays once and holds on Blossom. */
  loop?: boolean;
  /** How many petals fall (the drawing has 26; more are scattered the same way). */
  petals?: number;
  /** The night's colours. */
  palette?: MoonDragonPalette;
  /** The layers shift a little with the pointer, and the risen dragon's head follows it. */
  followPointer?: boolean;
  /** What the picture shows, for screen readers. */
  label?: string;
  /** Light or dark frame and controls, or the reader's ('system'); without it, the page's. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, take, visible, reduced, taken } = useAutoplay<HTMLDivElement>(10000);
  const [paused, setPaused] = useState(!autoplay);
  const id = useId().replace(/:/g, '');
  const first = autoplay ? START(startAt ?? 'moon') : STILL(startAt ?? 'rise');
  const [beat, setBeat] = useState<Beat>(() => beatAt(first).id);
  const [live, setLive] = useState('');
  const clock = useRef({
    t: first,
    time: first,
    last: 0,
    frame: 0,
    hold: null as number | null,
    look: { x: 0, y: 0 },
    beat: beatAt(first).id as Beat,
    rate: 1,
  });
  /** The pointer, from -1 to 1 across the stage, or null when it is away. */
  const target = useRef<Point | null>(null);
  const nodes = useRef<Record<string, Element>>({});
  const [wake, setWake] = useState(0);
  const running = visible && !reduced && !paused;

  // Under reduced motion, a still story: it opens on its last tableau. Otherwise, a change of
  // autoplay or opening beat starts over there.
  useEffect(() => {
    const c = clock.current;
    c.t = reduced
      ? STILL(startAt ?? 'blossom')
      : autoplay
        ? START(startAt ?? 'moon')
        : STILL(startAt ?? 'rise');
    c.hold = null;
    setPaused(!autoplay);
    setWake((n) => n + 1);
  }, [reduced, autoplay, startAt]);

  // The reader's hold ends when they leave it alone: the story plays on (if it autoplays).
  useEffect(() => {
    if (!taken && autoplay) clock.current.hold = null;
  }, [taken, autoplay]);

  const announce = useEffectEvent((b: Beat, spoken: boolean) => {
    setBeat(b);
    onBeat?.(b);
    if (spoken) setLive(BEATS.find((x) => x.id === b)!.text);
  });

  /** Draws the story at its clock: every moving part, by attribute, transform or opacity. */
  const draw = useEffectEvent(() => {
    const c = clock.current;
    const n = nodes.current;
    const element = root.current;
    if (!element) return;
    const { t, time, look } = c;
    const set = (key: string, name: string, value: string) => n[key]?.setAttribute(name, value);
    const style = (key: string) => (n[key] as HTMLElement | undefined)?.style;

    // The moon: its glow, a pulse on the ambient clock, its halo and the light on the water.
    const light = lightsAt(t);
    const pulse = 0.5 - 0.5 * Math.cos(time * 2.6);
    const glow = light.moon * (0.62 + 0.38 * pulse);
    const sky = style('sky');
    if (sky) sky.transform = `scale(${(1 + 0.035 * glow).toFixed(4)})`;
    const moon = style('moon');
    if (moon) moon.transform = `scale(${(1 + 0.012 * glow).toFixed(4)})`;
    const flare = style('flare');
    if (flare) flare.opacity = glow.toFixed(3);
    const sheen = style('sheen');
    if (sheen) sheen.opacity = (0.85 * glow).toFixed(3);
    for (let i = 0; i < 3; i++) {
      const ring = style(`ring${i}`);
      if (!ring) continue;
      const age = RINGS.filter((_, k) => k % 3 === i)
        .map((at) => t - at)
        .find((a) => a >= 0 && a < RING_LIFE);
      if (age === undefined) {
        ring.opacity = '0';
        continue;
      }
      const u = age / RING_LIFE;
      ring.transform = `scale(${(1.02 + 0.6 * (1 - (1 - u) ** 2)).toFixed(4)})`;
      ring.opacity = (Math.min(1, age / 0.4) * (1 - u) ** 1.4).toFixed(3);
    }

    // The dragon.
    const state = dragonAt(t, time, look);
    const shape = pose(state);
    set('body', 'd', shape.body);
    set('head', 'transform', shape.head);
    const [left, right] = whiskers(time, Math.min(1, state.swim));
    set('whiskerLeft', 'd', left);
    set('whiskerRight', 'd', right);
    const under = style('under');
    if (under) under.opacity = light.under.toFixed(3);
    const eye = style('eye');
    if (eye) eye.opacity = light.eye.toFixed(3);

    // The water's marks.
    const water = waterAt(t);
    for (let i = 0; i < RIPPLES; i++) {
      const r = water.ripples[i];
      const key = `ripple${i}`;
      if (!r) {
        set(key, 'opacity', '0');
        continue;
      }
      set(key, 'cx', r.x.toFixed(1));
      set(key, 'cy', r.y.toFixed(1));
      set(key, 'rx', r.rx.toFixed(1));
      set(key, 'ry', r.ry.toFixed(1));
      set(key, 'opacity', r.opacity.toFixed(3));
    }
    set('drops', 'd', water.drops || 'M0 0');
    set('bubbles', 'd', water.bubbles || 'M0 0');
    set(
      'bulge',
      'transform',
      `translate(${water.bulge.x.toFixed(1)} ${SURFACE}) scale(${water.bulge.scale.toFixed(3)})`,
    );
    set('bulge', 'opacity', water.bulge.opacity.toFixed(3));

    // The swordsman.
    const figure = rig(stanceAt(t, time));
    set('skirt', 'd', figure.skirt);
    set('torso', 'transform', figure.torso);
    set('figureHead', 'transform', figure.head);
    set('sword', 'transform', figure.sword);
    set('sheath', 'transform', figure.sheath);
    set('armFront', 'd', figure.front);
    set('armBack', 'd', figure.back);
    figure.hands.forEach((hand, i) => {
      set(`hand${i}`, 'cx', hand.x.toFixed(2));
      set(`hand${i}`, 'cy', hand.y.toFixed(2));
    });

    // Light around the dragon, the petals' colour and pace, the chapters' progress, parallax.
    const sparks = style('sparks');
    if (sparks) sparks.opacity = light.sparks.toFixed(3);
    const bloomSparks = style('bloomSparks');
    if (bloomSparks) {
      bloomSparks.opacity = light.bloomSparks.toFixed(3);
      bloomSparks.transform = `scale(${(0.9 + 0.1 * light.bloomSparks).toFixed(3)})`;
    }
    if (light.bloom) element.dataset.bloom = '';
    else delete element.dataset.bloom;
    if (Math.abs(light.petalRate - c.rate) > 0.015 && !reduced) {
      c.rate = light.petalRate;
      element.querySelectorAll('[data-depth]').forEach((layer) =>
        layer.getAnimations({ subtree: true }).forEach((animation) => {
          animation.playbackRate = c.rate;
        }),
      );
    }
    CHAPTERS.forEach((chapter, i) => {
      const fill = style(`fill${i}`);
      if (fill) {
        const done = Math.min(1, Math.max(0, (t - chapter.start) / (chapter.end - chapter.start)));
        fill.transform = `scaleX(${done.toFixed(4)})`;
      }
    });
    element.style.setProperty('--mx', look.x.toFixed(4));
    element.style.setProperty('--my', look.y.toFixed(4));

    const now = beatAt(t).id;
    if (now !== c.beat) {
      c.beat = now;
      announce(now, taken);
    }
  });

  useEffect(() => {
    const element = root.current;
    if (!element) return;
    nodes.current = {};
    element.querySelectorAll('[data-k]').forEach((node) => {
      nodes.current[(node as HTMLElement).dataset.k!] = node;
    });
  }, [root, petals]);

  // One loop draws the story: it runs while the scene plays, or while the dragon still has to
  // settle on the pointer; otherwise it draws once and stops.
  useEffect(() => {
    const c = clock.current;
    const tick = (now: number) => {
      const dt = Math.min(0.05, c.last ? (now - c.last) / 1000 : 0);
      c.last = now;
      if (running) {
        c.time += dt * speed;
        const end = c.hold ?? (loop ? Infinity : BLOSSOM_END - 0.01);
        if (c.t < end) {
          c.t = Math.min(end, c.t + dt * speed);
          if (c.t >= TOTAL) c.t -= TOTAL;
        }
      }
      const aim = followPointer && !reduced && target.current ? target.current : { x: 0, y: 0 };
      const ease = 1 - Math.exp(-dt * 2.6);
      c.look.x += (aim.x - c.look.x) * ease;
      c.look.y += (aim.y - c.look.y) * ease;
      draw();
      const settling = Math.abs(aim.x - c.look.x) + Math.abs(aim.y - c.look.y) > 0.002;
      if (running || settling) c.frame = requestAnimationFrame(tick);
      else c.frame = 0;
    };
    draw();
    c.last = 0;
    c.frame = requestAnimationFrame(tick);
    return () => {
      cancelAnimationFrame(c.frame);
      c.frame = 0;
    };
  }, [running, speed, reduced, followPointer, loop, wake, root]);

  const onPointer = (event: React.PointerEvent<HTMLDivElement>) => {
    if (!followPointer || reduced) return;
    const box = event.currentTarget.getBoundingClientRect();
    target.current = {
      x: Math.max(-1, Math.min(1, ((event.clientX - box.left) / box.width) * 2 - 1)),
      y: Math.max(-1, Math.min(1, ((event.clientY - box.top) / box.height) * 2 - 1)),
    };
    if (!clock.current.frame) setWake((n) => n + 1);
  };
  const onLeave = () => {
    target.current = null;
    if (!clock.current.frame) setWake((n) => n + 1);
  };

  /** Moves the story to `t` with a soft cut, holding at `hold` (or playing on). */
  const jump = (t: number, hold: number | null) => {
    const c = clock.current;
    const cut = nodes.current.cut as HTMLElement | undefined;
    if (!reduced && Math.abs(c.t - t) > 0.5)
      cut?.animate([{ opacity: 0.55 }, { opacity: 0 }], { duration: 650, easing: 'ease-out' });
    c.t = t;
    c.hold = hold;
    const now = beatAt(t).id;
    c.beat = now;
    announce(now, true);
    setWake((n) => n + 1);
  };
  const toChapter = (i: number) => {
    take();
    const chapter = CHAPTERS[i];
    if (reduced) jump(chapter.still, chapter.still);
    else {
      jump(chapter.start, chapter.end - 0.05);
      setPaused(false);
    }
  };
  const summon = () => {
    take();
    const c = clock.current;
    if (reduced) return jump(STILL('rise'), STILL('rise'));
    // With the dragon away, it is called up; with it here, the story just plays on.
    if (c.t < START('approach') || c.t > 37.5) jump(START('approach'), null);
    else {
      c.hold = null;
      setLive('The story plays on.');
    }
    setPaused(false);
  };
  const toggle = () => {
    take();
    setPaused((p) => {
      if (p) clock.current.hold = null;
      setLive(p ? 'Playing.' : 'Paused.');
      return !p;
    });
  };
  const current = CHAPTERS.findIndex((chapter) => chapter.id === beat);
  const onChapterKey = (event: ReactKeyboardEvent<HTMLDivElement>) => {
    const from = Math.max(0, current);
    const next =
      event.key === 'ArrowRight' || event.key === 'ArrowDown'
        ? (from + 1) % CHAPTERS.length
        : event.key === 'ArrowLeft' || event.key === 'ArrowUp'
          ? (from - 1 + CHAPTERS.length) % CHAPTERS.length
          : event.key === 'Home'
            ? 0
            : event.key === 'End'
              ? CHAPTERS.length - 1
              : -1;
    if (next < 0) return;
    event.preventDefault();
    toChapter(next);
    (event.currentTarget.children[next] as HTMLElement | undefined)?.focus();
  };

  // The first frame, as the server draws it.
  const t0 = first;
  const shape0 = pose(dragonAt(t0, t0));
  const figure0 = rig(stanceAt(t0, t0));
  const light0 = lightsAt(t0);
  const [whiskerA, whiskerB] = whiskers(t0, 1);
  const list = scatter(Math.max(0, Math.min(80, Math.round(petals))));
  const u = (n: number) => `calc(var(--u) * ${n})`;
  const canvas = '-48 -36 1696 1272';
  const ref = (name: string) => `url(#${id}-${name})`;
  const focusable = current < 0 ? 0 : current;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="stage"
      data-palette={palette}
      data-color-scheme={colorScheme}
      data-playing={running || undefined}
      data-bloom={light0.bloom ? '' : undefined}
      style={{ '--speed': Math.max(0.05, speed) } as CSSProperties}
      onPointerMove={onPointer}
      onPointerDown={onPointer}
      onPointerLeave={onLeave}
    >
      <div className={styles.scene} role="img" aria-label={label}>
        {/* The sky and the moon's halo. */}
        <svg
          className={`${styles.layer} ${styles.swell}`}
          viewBox={canvas}
          data-part="sky"
          data-k="sky"
          style={depth(0.05)}
        >
          <defs>
            <radialGradient
              id={`${id}-outer`}
              gradientUnits="userSpaceOnUse"
              cx={MOON.x}
              cy={MOON.y}
              r="660"
            >
              <stop offset="0.72" className={styles.stopHalo3} />
              <stop offset="0.86" className={styles.stopHalo4} />
              <stop offset="1" className={styles.stopSky} />
            </radialGradient>
            <radialGradient
              id={`${id}-inner`}
              gradientUnits="userSpaceOnUse"
              cx={MOON.x}
              cy={MOON.y - 4}
              r="477"
            >
              <stop offset="0.72" className={styles.stopHalo1} />
              <stop offset="1" className={styles.stopHalo2} />
            </radialGradient>
          </defs>
          <rect className={styles.sky} x="-148" y="-136" width="1896" height="1472" />
          <circle cx={MOON.x} cy={MOON.y} r="660" fill={ref('outer')} />
          <circle cx={MOON.x} cy={MOON.y - 4} r="477" fill={ref('inner')} />
        </svg>
        <div className={`${styles.layer} ${styles.glow}`} data-part="glow" style={depth(0.05)} />
        <div
          className={`${styles.layer} ${styles.flare}`}
          data-k="flare"
          style={{ ...depth(0.05), opacity: 0 }}
        />
        {/* The halo's rings, cut off at the horizon. */}
        <div className={styles.layer} style={depth(0.06)}>
          <div className={styles.ringClip}>
            {[0, 1, 2].map((i) => (
              <span key={i} className={styles.ring} data-k={`ring${i}`} style={{ opacity: 0 }} />
            ))}
          </div>
        </div>

        <Petals list={list} depth={0} u={u} />

        {/* The far clouds, on the horizon. */}
        <svg
          className={`${styles.layer} ${styles.driftFar}`}
          viewBox={canvas}
          data-part="far-clouds"
          style={depth(0.2)}
        >
          <defs>
            <clipPath id={`${id}-sky`}>
              <rect x="-48" y="-36" width="1696" height={HORIZON + 38} />
            </clipPath>
            {BACK_CLOUDS.map((cloud, i) => (
              <linearGradient
                key={i}
                id={`${id}-lump${i}`}
                gradientUnits="userSpaceOnUse"
                x1="0"
                y1={Math.min(...cloud.lumps.map((l) => l.y - l.r))}
                x2="0"
                y2={HORIZON}
              >
                <stop offset="0" className={styles.stopCloudTop} />
                <stop offset="1" className={styles.stopCloudBottom} />
              </linearGradient>
            ))}
          </defs>
          {BACK_CLOUDS.map((cloud, i) => (
            <path
              key={i}
              d={cloudPath(cloud)}
              fill={ref(`lump${i}`)}
              opacity={cloud.alpha}
              clipPath={ref('sky')}
            />
          ))}
        </svg>

        {/* The moon. */}
        <svg
          className={`${styles.layer} ${styles.swell}`}
          viewBox={canvas}
          data-part="moon"
          data-k="moon"
          style={depth(0.08)}
        >
          <defs>
            <clipPath id={`${id}-moon`}>
              <circle cx={MOON.x} cy={MOON.y} r={MOON.r} />
            </clipPath>
            <clipPath id={`${id}-above`}>
              <rect x="-48" y="-36" width="1696" height={HORIZON + 36} />
            </clipPath>
            <filter id={`${id}-mist-moon`} x="-20%" y="-20%" width="140%" height="140%">
              <feGaussianBlur stdDeviation="7" />
            </filter>
          </defs>
          <g clipPath={ref('above')}>
            <circle
              className={styles.moon}
              cx={MOON.x}
              cy={MOON.y}
              r={MOON.r}
              data-part="moon-disc"
            />
            <g clipPath={ref('moon')}>
              {CRATERS.map((c) => (
                <circle key={`${c.x}`} className={styles.crater} cx={c.x} cy={c.y} r={c.r} />
              ))}
              <g className={styles.moonMist} filter={ref('mist-moon')}>
                <ellipse cx="1065" cy="705" rx="95" ry="52" />
                <ellipse cx="985" cy="728" rx="70" ry="30" />
              </g>
            </g>
          </g>
        </svg>

        {/* The water. */}
        <svg className={styles.layer} viewBox={canvas} data-part="water" style={depth(0.12)}>
          <defs>
            <radialGradient
              id={`${id}-pool`}
              gradientUnits="userSpaceOnUse"
              cx={MOON.x}
              cy={MOON.y}
              r="480"
            >
              <stop offset="0.8" className={styles.stopPool1} />
              <stop offset="1" className={styles.stopPool2} />
            </radialGradient>
            <linearGradient id={`${id}-mirror`} x1="0" y1="0" x2="0" y2="1">
              <stop offset="0" className={styles.stopMirrorTop} />
              <stop offset="1" className={styles.stopMirrorBottom} />
            </linearGradient>
            <filter id={`${id}-haze`} x="-10%" y="-30%" width="120%" height="160%">
              <feGaussianBlur stdDeviation="5" />
            </filter>
          </defs>
          <rect className={styles.water} x="-48" y={HORIZON} width="1696" height={1236 - HORIZON} />
          <path
            d={`M${MOON.x - 480} ${HORIZON}A480 480 0 0 0 ${MOON.x + 480} ${HORIZON}Z`}
            fill={ref('pool')}
          />
          <g filter={ref('haze')}>
            <path
              d="M-48 741H340C330 800 300 860 250 905C200 935 120 930 40 912L-48 905Z"
              fill={ref('mirror')}
            />
            <path
              d="M1150 741H1648V905C1590 912 1520 912 1450 905C1380 900 1290 880 1230 840C1190 810 1160 780 1150 741Z"
              fill={ref('mirror')}
              opacity="0.8"
            />
          </g>
        </svg>

        {/* The moon's reflection: three sets of strokes, shimmering out of step. */}
        {[0, 1, 2].map((layer) => (
          <svg
            key={layer}
            className={`${styles.layer} ${styles.shimmer}`}
            viewBox={canvas}
            data-part={layer ? undefined : 'reflection'}
            data-layer={layer}
            style={depth(0.12)}
          >
            <defs>
              <linearGradient
                id={`${id}-glint${layer}`}
                gradientUnits="userSpaceOnUse"
                x1="0"
                y1={HORIZON}
                x2="0"
                y2="1075"
              >
                <stop offset="0" className={styles.stopGlint1} />
                <stop offset="0.45" className={styles.stopGlint2} />
                <stop offset="1" className={styles.stopGlint3} />
              </linearGradient>
              <filter id={`${id}-brush${layer}`} x="-10%" y="-50%" width="120%" height="200%">
                <feGaussianBlur stdDeviation="20 4" />
              </filter>
            </defs>
            <g filter={ref(`brush${layer}`)} fill={ref(`glint${layer}`)}>
              {STROKES.filter((s) => s.layer === layer).map((s) => (
                <rect
                  key={s.y}
                  x={s.x.toFixed(1)}
                  y={s.y}
                  width={s.w.toFixed(1)}
                  height={s.h}
                  rx={s.h / 2}
                />
              ))}
            </g>
          </svg>
        ))}
        {/* The moon's light flaring on the water. */}
        <div
          className={`${styles.layer} ${styles.sheen}`}
          data-k="sheen"
          style={{ ...depth(0.12), opacity: 0 }}
        />

        {/* A shadow across the reflection's far side. */}
        <svg className={styles.layer} viewBox={canvas} style={depth(0.12)}>
          <defs>
            <filter id={`${id}-shade`} x="-30%" y="-30%" width="160%" height="160%">
              <feGaussianBlur stdDeviation="28" />
            </filter>
          </defs>
          <path
            className={styles.shade}
            d="M990 770L1170 760L1150 1070L820 1070C900 960 960 860 990 770Z"
            filter={ref('shade')}
          />
        </svg>

        {/* The boat's reflection, upside down under the keel. */}
        <div className={`${styles.layer} ${styles.boatReflection}`} style={depth(0.16)}>
          <svg className={styles.fill} viewBox={canvas} data-part="boat-reflection">
            <defs>
              <filter id={`${id}-wet`} x="-20%" y="-20%" width="140%" height="140%">
                <feGaussianBlur stdDeviation="2.5" />
              </filter>
            </defs>
            <g
              transform={`translate(0 ${KEEL * 2}) scale(1 -1)`}
              filter={ref('wet')}
              className={styles.mirrored}
            >
              <use href={`#${id}-figure`} />
              <rect
                x={LANTERN.x}
                y={LANTERN.y}
                width={LANTERN.w}
                height={LANTERN.h}
                rx={LANTERN.r}
              />
            </g>
          </svg>
        </div>

        {/* The dragon: under the water a blurred dark shape, above it the drawing, cut at the
            surface; then the water it moves. */}
        <svg className={styles.layer} viewBox={canvas} data-part="dragon" style={depth(0.16)}>
          <defs>
            <linearGradient
              id={`${id}-hide`}
              gradientUnits="userSpaceOnUse"
              x1="985"
              y1="505"
              x2="1205"
              y2="235"
            >
              <stop offset="0" className={styles.stopDragon1} />
              <stop offset="0.3" className={styles.stopDragon2} />
              <stop offset="0.62" className={styles.stopDragon3} />
              <stop offset="1" className={styles.stopDragon4} />
            </linearGradient>
            <radialGradient
              id={`${id}-eye`}
              gradientUnits="userSpaceOnUse"
              cx={EYE_CENTRE.x}
              cy={EYE_CENTRE.y}
              r="72"
            >
              <stop offset="0" className={styles.stopEyeGlow} />
              <stop offset="1" className={styles.stopEyeGlowEnd} />
            </radialGradient>
            <radialGradient id={`${id}-swell`}>
              <stop offset="0" className={styles.stopSwell} />
              <stop offset="1" className={styles.stopSwellEnd} />
            </radialGradient>
            <clipPath id={`${id}-air`}>
              <rect x="-48" y="-36" width="1696" height={SURFACE + 36} />
            </clipPath>
            <clipPath id={`${id}-deep`}>
              <rect x="-48" y={SURFACE} width="1696" height={1236 - SURFACE} />
            </clipPath>
            <filter id={`${id}-murk`} x="-20%" y="-20%" width="140%" height="140%">
              <feGaussianBlur stdDeviation="6" />
            </filter>
          </defs>
          <g clipPath={ref('deep')}>
            <g
              className={styles.under}
              filter={ref('murk')}
              data-k="under"
              style={{ opacity: light0.under }}
            >
              <g transform={`translate(0 ${SURFACE}) scale(1 ${DEPTH}) translate(0 ${-SURFACE})`}>
                <use href={`#${id}-body`} />
                <use href={`#${id}-head`} />
              </g>
            </g>
          </g>
          <ellipse
            data-k="bulge"
            rx="120"
            ry="26"
            fill={ref('swell')}
            opacity="0"
            transform={`translate(0 ${SURFACE})`}
          />
          <g clipPath={ref('air')}>
            <path
              id={`${id}-body`}
              data-k="body"
              d={shape0.body}
              fill={ref('hide')}
              data-part="body"
            />
            <g id={`${id}-head`} data-k="head" transform={shape0.head} data-part="head">
              <path d={polygon(HEAD)} fill={ref('hide')} />
              <g data-k="eye" style={{ opacity: light0.eye }}>
                <circle
                  className={styles.eyeGlow}
                  cx={EYE_CENTRE.x}
                  cy={EYE_CENTRE.y}
                  r="72"
                  fill={ref('eye')}
                />
              </g>
              <path className={styles.eye} d={EYE} data-part="eye" />
              <path
                data-k="whiskerLeft"
                className={styles.whisker}
                d={whiskerA}
                data-part="whisker"
              />
              <path data-k="whiskerRight" className={styles.whisker} d={whiskerB} />
            </g>
          </g>
          <g className={styles.ripples}>
            {Array.from({ length: RIPPLES }, (_, i) => (
              <ellipse key={i} data-k={`ripple${i}`} cx="0" cy="0" rx="1" ry="1" opacity="0" />
            ))}
          </g>
          <path data-k="drops" className={styles.drops} d="M0 0" />
          <path data-k="bubbles" className={styles.bubbles} d="M0 0" />
        </svg>

        <Petals list={list} depth={1} u={u} />

        {/* The boat, the swordsman and the lantern. */}
        <div className={`${styles.layer} ${styles.boat}`} style={depth(0.16)}>
          <svg className={styles.fill} viewBox={canvas} data-part="boat">
            <defs>
              <linearGradient
                id={`${id}-hull`}
                gradientUnits="userSpaceOnUse"
                x1="640"
                y1="650"
                x2="735"
                y2="800"
              >
                <stop offset="0" className={styles.stopHull1} />
                <stop offset="0.5" className={styles.stopHull2} />
                <stop offset="1" className={styles.stopHull3} />
              </linearGradient>
            </defs>
            <path d={HULL} fill={ref('hull')} />
            <g id={`${id}-figure`} className={styles.figure} data-part="figure">
              <g data-k="sheath" transform={figure0.sheath}>
                <path d={SHEATH} />
              </g>
              <path data-k="skirt" d={figure0.skirt} />
              <path data-k="armBack" className={styles.limb} d={figure0.back} />
              <g data-k="torso" transform={figure0.torso}>
                <path d={TORSO} />
                <g data-k="figureHead" transform={figure0.head}>
                  <path d={HEAD_SHAPE} />
                  <path d={HAT_SHAPE} />
                </g>
              </g>
              <g data-k="sword" transform={figure0.sword} data-part="sword">
                <path d={SWORD.blade} />
                <path d={SWORD.guard} />
                <path d={SWORD.grip} />
              </g>
              <path data-k="armFront" className={styles.limb} d={figure0.front} />
              {figure0.hands.map((hand, i) => (
                <circle key={i} data-k={`hand${i}`} cx={hand.x} cy={hand.y} r="3.2" />
              ))}
            </g>
            <rect
              className={styles.lantern}
              x={LANTERN.x}
              y={LANTERN.y}
              width={LANTERN.w}
              height={LANTERN.h}
              rx={LANTERN.r}
              data-part="lantern"
            />
          </svg>
          <span className={styles.lanternGlow} aria-hidden="true" />
        </div>

        {/* Specks of light: by the boat, round the dragon's head, and more when it answers. */}
        <div className={styles.layer} style={depth(0.3)}>
          <Sparks list={SPARKS.slice(13)} />
          <div className={styles.group} data-k="sparks" style={{ opacity: light0.sparks }}>
            <Sparks list={SPARKS.slice(0, 13)} />
          </div>
          <div
            className={`${styles.group} ${styles.bloomSparks}`}
            data-k="bloomSparks"
            style={{ opacity: light0.bloomSparks }}
          >
            <Sparks list={BLOOM_SPARKS} />
          </div>
        </div>

        {/* The low cloud on the water, then the near clouds. */}
        <svg
          className={`${styles.layer} ${styles.driftMid}`}
          viewBox={canvas}
          data-part="mid-cloud"
          style={depth(0.45)}
        >
          <defs>
            <filter id={`${id}-mist`} x="-10%" y="-30%" width="120%" height="160%">
              <feGaussianBlur stdDeviation="3.5" />
            </filter>
          </defs>
          <path className={styles.midCloud} d={MID_CLOUD} filter={ref('mist')} />
        </svg>
        <svg
          className={`${styles.layer} ${styles.driftNear}`}
          viewBox={canvas}
          data-part="near-clouds"
          style={depth(0.8)}
        >
          <defs>
            <filter id={`${id}-fog`} x="-20%" y="-30%" width="140%" height="160%">
              <feGaussianBlur stdDeviation="7" />
            </filter>
            <filter id={`${id}-fog2`} x="-20%" y="-30%" width="140%" height="160%">
              <feGaussianBlur stdDeviation="11" />
            </filter>
          </defs>
          <path className={styles.nearCloud} d={FRONT_LEFT} filter={ref('fog')} />
          <path className={styles.nearCloud} d={FRONT_RIGHT} filter={ref('fog2')} />
        </svg>

        <Petals list={list} depth={2} u={u} />
        <div className={styles.cut} data-k="cut" />
      </div>

      {/* The moon itself calls the dragon (the Summon button does the same, by keyboard too). */}
      <button
        type="button"
        className={styles.moonHit}
        tabIndex={-1}
        aria-hidden="true"
        onClick={summon}
        data-part="moon-button"
      />

      <div className={styles.controls} data-part="controls">
        {!reduced && (
          <button
            type="button"
            className={styles.toggle}
            aria-pressed={paused}
            aria-label={paused ? 'Play the story' : 'Pause the story'}
            onClick={toggle}
            data-part="toggle"
          >
            <svg viewBox="0 0 16 16" aria-hidden="true">
              {paused ? (
                <path d="M5 3.5v9l7.5-4.5z" />
              ) : (
                <path d="M4.5 3.5h2.5v9H4.5zM9 3.5h2.5v9H9z" />
              )}
            </svg>
          </button>
        )}
        <div
          className={styles.chapters}
          role="toolbar"
          aria-label="Chapters"
          data-part="chapters"
          onKeyDown={onChapterKey}
        >
          {CHAPTERS.map((chapter, i) => (
            <button
              key={chapter.id}
              type="button"
              className={styles.chapter}
              aria-current={i === current ? 'step' : undefined}
              tabIndex={i === focusable ? 0 : -1}
              onClick={() => toChapter(i)}
              data-part={i === 0 ? 'chapter' : undefined}
            >
              <span className={styles.chapterLabel}>{chapter.label}</span>
              <span className={styles.track} aria-hidden="true">
                <span
                  className={styles.progress}
                  data-k={`fill${i}`}
                  style={{
                    transform: `scaleX(${Math.min(1, Math.max(0, (t0 - chapter.start) / (chapter.end - chapter.start)))})`,
                  }}
                />
              </span>
            </button>
          ))}
        </div>
        <button type="button" className={styles.summon} onClick={summon} data-part="summon">
          <svg viewBox="0 0 16 16" aria-hidden="true">
            <path d="M10.5 2.2A6 6 0 1 0 13.8 11 5 5 0 0 1 10.5 2.2Z" />
          </svg>
          <span>Summon</span>
        </button>
      </div>
      <p className={styles.live} aria-live="polite">
        {live}
      </p>
    </div>
  );
}

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

function Sparks({ list }: { list: { x: number; y: number; r: number }[] }) {
  return list.map((spark, i) => (
    <span
      key={i}
      className={styles.spark}
      data-part="spark"
      style={
        {
          left: `${((spark.x + 48) / 1696) * 100}%`,
          top: `${((spark.y + 36) / 1272) * 100}%`,
          '--r': spark.r,
          animationDelay: `${-((i * 0.77 + spark.x * 0.01) % 3.1).toFixed(2)}s`,
          animationDuration: `calc(${(2.4 + (i % 5) * 0.45).toFixed(2)}s / var(--speed))`,
        } as CSSProperties
      }
    />
  ));
}

/** Fall speed by depth, in canvas units a second. */
const FALL = [38, 62, 120];
/** Where the white wave of the petals starts: the dragon's head. */
const WAVE = { x: 1000, y: 420 };

/** The petals at one depth: each falls, sways and tumbles, starting from where the drawing has it. */
function Petals({
  list,
  depth: level,
  u,
}: {
  list: ReturnType<typeof scatter>;
  depth: 0 | 1 | 2;
  u: (n: number) => string;
}) {
  return (
    <div className={styles.petals} data-depth={level} style={depth([0.18, 0.5, 1][level])}>
      {list.map((petal, i) => {
        if (petal.depth !== level) return null;
        const top = -80;
        const bottom = 1300;
        const fall = (bottom - top) / FALL[level];
        const done = (petal.y - top) / (bottom - top);
        const wind = 0.22 + (i % 4) * 0.05;
        const wave = Math.min(1, Math.hypot(petal.x - WAVE.x, petal.y - WAVE.y) / 1100);
        return (
          <span
            key={i}
            className={styles.petal}
            style={
              {
                left: `${(petal.x / 1600) * 100}%`,
                top: `${(petal.y / 1200) * 100}%`,
                '--from-x': u((petal.y - top) * wind),
                '--from-y': u(-(petal.y - top)),
                '--to-x': u(-(bottom - petal.y) * wind),
                '--to-y': u(bottom - petal.y),
                '--wave': wave.toFixed(3),
                animationDuration: `calc(${fall.toFixed(2)}s / var(--speed))`,
                animationDelay: `calc(${(-done * fall).toFixed(2)}s / var(--speed))`,
              } as CSSProperties
            }
          >
            <span
              className={styles.sway}
              style={
                {
                  '--sway': u(14 + (i % 3) * 8),
                  animationDuration: `calc(${(3.2 + (i % 5) * 0.6).toFixed(2)}s / var(--speed))`,
                } as CSSProperties
              }
            >
              <span
                className={styles.halo}
                style={{ width: u(petal.l * 2.4), height: u(petal.l * 2.4) }}
              />
              <svg
                className={styles.leaf}
                data-part="petal"
                viewBox="0 0 100 52"
                preserveAspectRatio="none"
                style={
                  {
                    width: u(petal.l),
                    height: u(petal.w * 1.2),
                    '--a': `${petal.a}deg`,
                    '--blur': u(petal.blur ?? 1.6),
                    opacity: petal.dim ?? 1,
                    animationDuration: `calc(${(6 + (i % 7) * 1.3).toFixed(2)}s / var(--speed))`,
                    animationDirection: i % 2 ? 'reverse' : 'normal',
                  } as CSSProperties
                }
              >
                <path className={styles.tint} d="M0 24C12 4 62 -2 100 20C84 46 24 56 0 24Z" />
                <path className={styles.white} d="M0 24C12 4 62 -2 100 20C84 46 24 56 0 24Z" />
              </svg>
            </span>
          </span>
        );
      })}
    </div>
  );
}
```

### moon-dragon/story.ts

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

import {
  MARKS,
  SNOUT,
  SURFACE,
  headPoint,
  pose,
  seen,
  smooth,
  spinePoint,
  type DragonState,
  type Point,
} from './scene';
import type { Stance } from './rig';

/*
 * The story, as pure functions of its clock (seconds from the start of the loop): every frame of
 * it can be drawn from the time alone, so the reader can jump between beats and the loop closes
 * on itself (the last frame is the first). Slow, living motion that never stops (the waves, the
 * breath, the moon's pulse) runs on a second, ambient clock instead.
 */

export type Beat = 'moon' | 'approach' | 'rise' | 'bow' | 'blossom' | 'return';

export const BEATS: {
  id: Beat;
  label: string;
  start: number;
  end: number;
  /** The beat's tableau: the moment shown when the beat is held still. */
  still: number;
  text: string;
}[] = [
  {
    id: 'moon',
    label: 'Moon',
    start: 0,
    end: 4.5,
    still: 3.3,
    text: 'The moon swells and glows; light flares on the water.',
  },
  {
    id: 'approach',
    label: 'Approach',
    start: 4.5,
    end: 11,
    still: 9.4,
    text: 'A dark shape glides under the water towards the boat.',
  },
  {
    id: 'rise',
    label: 'Rise',
    start: 11,
    end: 18.5,
    still: 18.3,
    text: 'The dragon rises out of the water and turns its head to the swordsman.',
  },
  {
    id: 'bow',
    label: 'Bow',
    start: 18.5,
    end: 23.5,
    still: 23.3,
    text: 'The swordsman stands, bows, and holds out his sword on both palms.',
  },
  {
    id: 'blossom',
    label: 'Blossom',
    start: 23.5,
    end: 30.5,
    still: 27.2,
    text: 'The petals turn white, and the dragon bows its head in answer.',
  },
  {
    id: 'return',
    label: 'Return',
    start: 30.5,
    end: 39,
    still: 34,
    text: 'The dragon sinks back into the water, and the petals turn violet again.',
  },
];
export const TOTAL = 39;
/** The beats a reader can pick (the return leads back into the moon). */
export const CHAPTERS = BEATS.slice(0, 5);

export const beatAt = (t: number) =>
  BEATS.find((b) => t >= b.start && t < b.end) ?? BEATS[BEATS.length - 1];

/** A cubic from (a, va) to (b, vb) over `span` seconds, at `u` of the way (0 to 1). */
function hermite(a: number, b: number, va: number, vb: number, span: number, u: number) {
  const u2 = u * u;
  const u3 = u2 * u;
  return (
    (2 * u3 - 3 * u2 + 1) * a +
    (u3 - 2 * u2 + u) * va * span +
    (-2 * u3 + 3 * u2) * b +
    (u3 - u2) * vb * span
  );
}
const span = (t: number, a: number, b: number) => Math.min(1, Math.max(0, (t - a) / (b - a)));
/** 0, up to 1 and back to 0 between a and b, softly. */
const bump = (t: number, a: number, b: number) => {
  const u = span(t, a, b);
  return Math.sin(Math.PI * u) ** 2;
};

/** How far the head is along its path: under the water, up and over, and down again. */
const UNDER = MARKS.breach - 250;
const FROM = 120;
export function headAlong(t: number) {
  if (t < 4.5) return FROM;
  if (t < 11) return hermite(FROM, UNDER, 0, 140, 6.5, span(t, 4.5, 11));
  if (t < 18.5) return hermite(UNDER, MARKS.hang, 140, 0, 7.5, span(t, 11, 18.5));
  if (t < 31) return MARKS.hang;
  if (t < 38.5) return hermite(MARKS.hang, MARKS.hang + 440, 0, 70, 7.5, span(t, 31, 38.5));
  return MARKS.hang + 440 + (t - 38.5) * 70;
}

/** The dragon at story time `t`, with the ambient clock and the reader's pointer (-1 to 1). */
export function dragonAt(t: number, time: number, look: Point = { x: 0, y: 0 }): DragonState {
  const hang = smooth(17.5, 18.5, t) * (1 - smooth(30.5, 31.5, t));
  const settle = smooth(14, 18.5, t);
  const nod = bump(t, 24.4, 28.2);
  const drift = {
    x: hang * (Math.sin(time * 0.37) * 10 + Math.sin(time * 0.91) * 3),
    y: hang * Math.sin(time * 0.53 + 1) * 7,
  };
  return {
    h: headAlong(t),
    time,
    swim: 1.6 - 0.6 * settle + 0.4 * smooth(31, 33, t),
    lead: {
      x: drift.x + look.x * 30 * hang - nod * 10,
      y: drift.y + look.y * 20 * hang + nod * 22,
    },
    turn:
      hang * (Math.sin(time * 0.29) * 0.03 + look.x * 0.028 - look.y * 0.07) -
      0.12 * smooth(16.5, 18.5, t) * (1 - smooth(31, 33, t)) -
      0.17 * nod,
    sink: 800 * smooth(31.6, 38.4, t),
    lift: smooth(15.2, 18.5, t),
  };
}

/** The swordsman's stance at story time `t`. */
export function stanceAt(t: number, time: number): Stance {
  const up = span(t, 18.6, 21);
  const down = span(t, 33.2, 36);
  const bowed = smooth(21, 22.6, t) * (1 - smooth(31, 33.2, t));
  return {
    rise: smooth(18.6, 21, t) * (1 - smooth(33.2, 36, t)),
    lean:
      14 * Math.sin(Math.PI * up) +
      10 * Math.sin(Math.PI * down) +
      bowed * (30 - 24 * smooth(23.8, 26.4, t)) +
      0.6 * Math.sin(time * 0.9),
    nod:
      -12 * smooth(12.5, 15, t) * (1 - smooth(19, 20.6, t)) +
      14 * smooth(21, 22.6, t) * (1 - smooth(23.8, 26.4, t)) -
      14 * smooth(23.8, 26.4, t) * (1 - smooth(30, 32, t)),
    offer: smooth(21.2, 22.9, t) * (1 - smooth(30.8, 32.8, t)),
    raise: smooth(23.8, 26.4, t) * (1 - smooth(29.8, 31.4, t)),
  };
}

/** The moon's glow, 0 to 1, before its pulse. */
const MOON_LEVELS: [number, number][] = [
  [0, 0.12],
  [1.6, 1],
  [4.5, 1],
  [8, 0.4],
  [11, 0.6],
  [18.5, 0.5],
  [23.5, 0.6],
  [25, 1],
  [30.5, 0.75],
  [36, 0.12],
  [39, 0.12],
];
export function moonAt(t: number) {
  for (let i = 1; i < MOON_LEVELS.length; i++) {
    const [b, vb] = MOON_LEVELS[i];
    const [a, va] = MOON_LEVELS[i - 1];
    if (t <= b) return va + (vb - va) * smooth(a, b, t);
  }
  return MOON_LEVELS[0][1];
}
/** When a halo ring leaves the moon. */
export const RINGS = [0.5, 1.8, 3.1, 24, 25.5];
export const RING_LIFE = 3.4;

/** Everything else the clock sets. */
export function lightsAt(t: number) {
  const out = 1 - smooth(31, 32.5, t);
  return {
    moon: moonAt(t),
    /** The dark shape under the water. */
    under: 0.82 * smooth(4.6, 6, t) * (1 - smooth(33.5, 37.5, t)),
    eye: (0.4 * smooth(11, 13, t) + 0.6 * smooth(16.5, 18.5, t) + 0.35 * smooth(24, 25.5, t)) * out,
    sparks: (0.75 * smooth(16.5, 18.5, t) + 0.25 * smooth(24, 25.5, t)) * out,
    bloomSparks: smooth(24.2, 26, t) * (1 - smooth(29.8, 31.5, t)),
    bloom: t >= 23.6 && t < 31.6,
    petalRate: 1 - 0.5 * smooth(23.6, 25.2, t) * (1 - smooth(30.5, 32.5, t)),
  };
}

/* ———————————————————————————— Water ———————————————————————————— */

/** A fixed random number for (i, k): the same every frame, so the water can be scrubbed. */
const random = (i: number, k: number) => {
  const x = Math.sin(i * 127.1 + k * 311.7) * 43758.5453;
  return x - Math.floor(x);
};

const snoutAt = (t: number) => seen(headPoint(pose(dragonAt(t, t)), SNOUT));
/** When the snout breaks the surface, and when it goes back under. */
const findCross = (from: number, to: number, under: boolean) => {
  for (let t = from; t < to; t += 0.02) if (snoutAt(t).y > SURFACE === under) return t;
  return to;
};
export const BREACH = findCross(10, 14, false);
export const DIVE = findCross(31, 38.5, true);
const BREACH_X = snoutAt(BREACH).x;
const DIVE_X = snoutAt(DIVE).x;

export type Ripple = { x: number; y: number; rx: number; ry: number; opacity: number };
export type Water = {
  ripples: Ripple[];
  /** Streaks of water in the air, as one path, and bubbles as another. */
  drops: string;
  bubbles: string;
  bulge: { x: number; scale: number; opacity: number };
};

const ring = (
  x: number,
  y: number,
  age: number,
  life: number,
  from: number,
  to: number,
  alpha: number,
) => {
  const u = age / life;
  const grow = 1 - (1 - u) * (1 - u);
  const rx = from + (to - from) * grow;
  return { x, y, rx, ry: rx * 0.26, opacity: alpha * (1 - u) * Math.min(1, age / 0.15) };
};

const crossCache = new Map<number, number[]>();
const crossingsAt = (t: number) => {
  const key = Math.round(t * 100);
  let hit = crossCache.get(key);
  if (!hit) {
    hit = pose(dragonAt(t, t)).crossings;
    if (crossCache.size > 200) crossCache.clear();
    crossCache.set(key, hit);
  }
  return hit;
};

/** The water's marks at story time `t`: rings, splashes, streams and bubbles. */
export function waterAt(t: number): Water {
  const ripples: Ripple[] = [];
  // The wake: rings over the head as it glides under the surface.
  for (let e = 5; e < BREACH; e += 0.45) {
    const age = t - e;
    if (age < 0 || age > 2.4) continue;
    const p = snoutAt(e);
    if (p.y < SURFACE + 4 || p.x < -40) continue;
    ripples.push(ring(p.x, p.y - 4, age, 2.4, 12, 90, 0.55));
  }
  // The breach and the dive: big rings.
  for (const [at, x, size] of [
    [BREACH, BREACH_X, 1],
    [DIVE, DIVE_X, 0.7],
  ] as const) {
    for (const lag of [0, 0.4, 0.9]) {
      const age = t - at - lag;
      if (age >= 0 && age < 3.2)
        ripples.push(ring(x, SURFACE, age, 3.2, 30 * size, 270 * size, 0.65));
    }
  }
  // Where the body stands in the water, rings keep spreading.
  for (let e = Math.ceil((BREACH + 0.8) / 0.9) * 0.9; e < 38.5; e += 0.9) {
    const age = t - e;
    if (age < 0 || age > 2.7) continue;
    for (const x of crossingsAt(e)) ripples.push(ring(x, SURFACE, age, 2.7, 34, 150, 0.4));
  }

  let drops = '';
  let bubbles = '';
  const streak = (x: number, y: number, vx: number, vy: number) => {
    if (y >= SURFACE + 2) return;
    drops += `M${x.toFixed(1)} ${y.toFixed(1)}l${(-vx * 0.03).toFixed(1)} ${(-vy * 0.03).toFixed(1)}`;
  };
  // The splash where it breaks the surface, and a smaller one where it dives.
  for (const [at, x0, count, power] of [
    [BREACH, BREACH_X, 54, 1],
    [DIVE, DIVE_X, 30, 0.7],
  ] as const) {
    for (let i = 0; i < count; i++) {
      const t0 = at + random(i, power) * 0.7;
      const age = t - t0;
      if (age < 0 || age > 2.2) continue;
      const vx = (random(i, 2) - 0.5) * 300 * power;
      const vy = -(220 + random(i, 3) * 420) * power;
      const x = x0 + (random(i, 4) - 0.5) * 100 * power + vx * age;
      const y = SURFACE - 4 + vy * age + 360 * age * age;
      streak(x, y, vx, vy + 720 * age);
    }
  }
  // Water streaming off the body as it rises.
  for (let i = 0; i < 170; i++) {
    const t0 = BREACH + 0.3 + random(i, 5) * 7.4;
    const age = t - t0;
    if (age < 0 || age > 2.4) continue;
    const state = dragonAt(t0, t0);
    const out = state.h - MARKS.breach;
    if (out <= 0) continue;
    const s = Math.max(0, out - 700) + random(i, 6) * Math.min(out, 700);
    const p = spinePoint(state, s);
    if (p.y > SURFACE - 10) continue;
    const vx = (random(i, 7) - 0.5) * 24;
    const vy = random(i, 8) * 30;
    const x = p.x + (random(i, 9) - 0.5) * 70 + vx * age;
    const y = p.y + (random(i, 10) - 0.5) * 50 + vy * age + 280 * age * age;
    streak(x, y, vx, vy + 560 * age + 60);
  }
  // Bubbles and glints over the gliding head.
  for (let i = 0; i < 38; i++) {
    const t0 = 5.2 + i * 0.17;
    const age = t - t0;
    if (age < 0 || age > 0.9 || t0 > BREACH) continue;
    const p = snoutAt(t0);
    if (p.y < SURFACE + 3 || p.x < -40) continue;
    const x = p.x + (random(i, 11) - 0.5) * 50;
    const y = p.y - 3 + (random(i, 12) - 0.5) * 10 - age * 6;
    const r = (1.6 + random(i, 13) * 2.2) * (1 - age / 0.9) + 0.4;
    bubbles += `M${(x - r).toFixed(1)} ${y.toFixed(1)}a${r.toFixed(1)} ${r.toFixed(1)} 0 1 0 ${(2 * r).toFixed(1)} 0a${r.toFixed(1)} ${r.toFixed(1)} 0 1 0 ${(-2 * r).toFixed(1)} 0`;
  }
  // The water swells over the head just before it breaks through.
  const swell = smooth(BREACH - 1.8, BREACH - 0.1, t);
  const bulge = {
    x: BREACH_X,
    scale: 0.3 + 0.7 * swell + 0.3 * smooth(BREACH - 0.1, BREACH + 0.5, t),
    opacity: swell * (1 - smooth(BREACH, BREACH + 0.6, t)),
  };
  return { ripples, drops, bubbles, bulge };
}
```

### moon-dragon/scene.ts

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

/*
 * The scene's drawing, in the units of a 1600 × 1200 canvas, and the dragon's motion.
 *
 * The dragon is two silhouettes. The head (with its antlers) is rigid; the body, from the neck to
 * past the right edge, is skinned on a spine: every point of its outline is stored once as a
 * distance along the spine and an offset from it, and each frame the spine bends (waves travel
 * down it from the head to the tail) and the outline is rebuilt around the bent spine. The head
 * rides the spine's first point and turns with it, so the neck never breaks.
 */

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

const parse = (points: string): Point[] =>
  points
    .trim()
    .split(/\s+/)
    .map((pair) => {
      const [x, y] = pair.split(',').map(Number);
      return { x, y };
    });

/** The body's outline, crest spikes, shoulder and foreleg included, at rest. */
const BODY = parse(`
  1396,19 1397,27 1395,30 1395,36 1399,40 1423,20 1424,31 1422,36 1426,40 1442,31 1458,27 1458,32
  1453,40 1457,44 1468,39 1475,39 1479,37 1487,38 1483,45 1487,49 1502,44 1517,43 1518,44 1516,47
  1507,57 1511,61 1528,55 1541,55 1540,60 1533,67 1537,71 1546,67 1564,65 1565,68 1557,78 1559,81
  1561,82 1577,77 1586,77 1586,80 1578,89 1578,93 1581,95 1599,97 1612,92 1624,90 1621,100 1640,108
  1656,103 1668,103 1662,114 1690,124 1730,134 1730,270 1650,240 1599,220 1576,207 1571,202
  1538,183 1534,179 1523,173 1516,173 1511,167 1486,152 1443,131 1430,130 1425,134 1424,142
  1436,163 1438,171 1438,184 1436,188 1432,192 1421,196 1411,196 1404,193 1395,192 1372,192
  1366,195 1363,199 1373,211 1372,220 1368,223 1363,222 1354,212 1346,215 1340,222 1340,241
  1339,244 1336,245 1331,244 1325,239 1325,232 1337,207 1350,191 1379,179 1387,173 1382,168
  1367,165 1343,165 1319,171 1292,186 1277,201 1267,207 1260,215 1255,217 1231,240 1223,250
  1210,282 1197,312 1183,347 1168,372 1165,374 1163,379 1143,398 1118,402 1085,362 1068,312
  1086,300 1096,296 1099,292 1098,272 1105,254 1120,236 1125,238 1129,236 1136,217 1145,205
  1151,211 1154,209 1161,186 1172,170 1175,170 1179,175 1182,173 1185,162 1193,146 1198,141
  1200,141 1205,147 1208,145 1209,138 1220,118 1224,114 1229,118 1232,116 1237,102 1246,90 1249,92
  1249,99 1251,102 1253,103 1257,100 1267,75 1274,67 1277,70 1279,79 1282,81 1285,79 1293,62
  1300,53 1303,55 1304,69 1307,71 1309,70 1319,47 1331,33 1336,33 1336,52 1340,56 1343,54 1351,41
  1364,30 1364,41 1368,45 1387,24 1395,20
`);

/** The head, its two antlers and the top of the neck, at rest. */
export const HEAD = parse(`
  1039,129 1041,129 1050,138 1059,156 1068,189 1068,206 1060,223 1032,254 1020,273 1017,283
  1017,299 1021,308 1030,318 1036,322 1042,323 1047,319 1051,303 1061,283 1065,257 1064,251
  1060,245 1062,243 1070,245 1077,237 1083,211 1083,192 1081,187 1083,185 1085,187 1091,203
  1095,208 1101,199 1105,178 1101,159 1097,150 1093,146 1089,138 1095,141 1106,156 1111,167
  1111,171 1114,175 1113,206 1101,239 1090,252 1079,260 1076,266 1072,286 1064,303 1067,306
  1072,305 1092,298 1099,293 1098,272 1101,262 1106,256 1166,373 1163,379 1139,403 1127,411
  1119,407 1115,407 1111,412 1113,417 1111,422 1090,436 1065,450 1042,461 1030,471 1011,492
  1012,494 1007,502 997,508 983,508 961,499 954,489 950,476 952,465 959,452 966,443 970,433
  983,413 981,400 979,388 981,377 986,367 990,358 996,346 1003,334 1010,327 1012,297
  1009,275 1009,259 1013,251 1031,231 1038,215 1037,194 1033,182 1020,157 1022,155 1030,163
  1033,170 1050,192 1053,189 1051,162 1049,160 1045,143 1037,132 1038,130
`);

/** The whiskers at rest, from the snout outwards. */
export const WHISKERS = [
  parse('958,478 948,467 934,460 920,460 905,467 892,475 880,478 869,474 862,466 857,458'),
  parse(
    '988,474 994,458 1003,447 1020,440 1040,437 1060,440 1080,450 1103,457 1120,453 1133,443 1143,430 1146,418',
  ),
];

/** The eye: an almond, pointed towards the snout. */
export const EYE =
  'M1027 379C1024 369 1031 357 1041 354C1049 352 1053 360 1050 367C1046 375 1037 378 1027 379Z';
export const EYE_CENTRE: Point = { x: 1040, y: 367 };

/** The spine at rest, from the neck (where the head hangs) towards the tail. */
const SPINE = parse(
  '1120,340 1172,266 1228,190 1294,132 1366,104 1450,90 1529,116 1600,155 1700,212 1820,262',
);

/** Catmull-Rom through `points`, sampled finely. */
function sampleCurve(points: Point[], steps = 24): Point[] {
  const out: Point[] = [];
  for (let i = 0; i < points.length - 1; i++) {
    const p0 = points[Math.max(0, i - 1)];
    const p1 = points[i];
    const p2 = points[i + 1];
    const p3 = points[Math.min(points.length - 1, i + 2)];
    for (let k = 0; k < steps; k++) {
      const t = k / steps;
      const t2 = t * t;
      const t3 = t2 * t;
      const f = (a: number, b: number, c: number, d: number) =>
        0.5 *
        (2 * b + (-a + c) * t + (2 * a - 5 * b + 4 * c - d) * t2 + (-a + 3 * b - 3 * c + d) * t3);
      out.push({ x: f(p0.x, p1.x, p2.x, p3.x), y: f(p0.y, p1.y, p2.y, p3.y) });
    }
  }
  out.push(points[points.length - 1]);
  return out;
}

/** A smooth SVG path through `points` (Catmull-Rom as cubic Béziers). */
export function smoothPath(points: Point[], closed = false): string {
  let d = `M${points[0].x.toFixed(1)} ${points[0].y.toFixed(1)}`;
  const n = points.length;
  const at = (i: number) =>
    closed ? points[(i + n) % n] : points[Math.max(0, Math.min(n - 1, i))];
  for (let i = 0; i < (closed ? n : n - 1); i++) {
    const p0 = at(i - 1);
    const p1 = at(i);
    const p2 = at(i + 1);
    const p3 = at(i + 2);
    const c1 = { x: p1.x + (p2.x - p0.x) / 6, y: p1.y + (p2.y - p0.y) / 6 };
    const c2 = { x: p2.x - (p3.x - p1.x) / 6, y: p2.y - (p3.y - p1.y) / 6 };
    d += `C${c1.x.toFixed(1)} ${c1.y.toFixed(1)} ${c2.x.toFixed(1)} ${c2.y.toFixed(1)} ${p2.x.toFixed(1)} ${p2.y.toFixed(1)}`;
  }
  return closed ? d + 'Z' : d;
}

export const polygon = (points: Point[]) =>
  'M' + points.map((p) => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join('L') + 'Z';

/** `points` resampled every `step` units of their length. */
function resample(points: Point[], step: number): { points: Point[]; marks: number[] } {
  const fine: Point[] = [];
  const marks: number[] = [];
  const steps = 40;
  sampleCurve(points, steps).forEach((p, i) => {
    if (i % steps === 0) marks.push(fine.length);
    fine.push(p);
  });
  const lengths = [0];
  for (let i = 1; i < fine.length; i++)
    lengths.push(lengths[i - 1] + Math.hypot(fine[i].x - fine[i - 1].x, fine[i].y - fine[i - 1].y));
  const total = lengths[lengths.length - 1];
  const out: Point[] = [];
  let j = 0;
  for (let s = 0; s <= total; s += step) {
    while (j < fine.length - 2 && lengths[j + 1] < s) j++;
    const k = (s - lengths[j]) / (lengths[j + 1] - lengths[j] || 1);
    out.push({
      x: fine[j].x + (fine[j + 1].x - fine[j].x) * k,
      y: fine[j].y + (fine[j + 1].y - fine[j].y) * k,
    });
  }
  return { points: out, marks: marks.map((m) => lengths[m]) };
}

/** The spine resampled every STEP units of its length. */
const STEP = 12;
type Frame = { p: Point; t: Point; n: Point };
const REST: Frame[] = frames(resample(SPINE, STEP).points);
const COUNT = REST.length;

/** Tangents and normals (the normal points to the spine's left: up, along the back). */
function frames(points: Point[]): Frame[] {
  return points.map((p, i) => {
    const a = points[Math.max(0, i - 1)];
    const b = points[Math.min(points.length - 1, i + 1)];
    const length = Math.hypot(b.x - a.x, b.y - a.y) || 1;
    const t = { x: (b.x - a.x) / length, y: (b.y - a.y) / length };
    return { p, t, n: { x: t.y, y: -t.x } };
  });
}

/** The frame at distance `s` along a spine sampled every `step`; past its ends it goes straight on. */
function frameAt(spine: Frame[], s: number, step = STEP): Frame {
  const u = s / step;
  if (u <= 0) {
    const f = spine[0];
    return { p: { x: f.p.x + f.t.x * s, y: f.p.y + f.t.y * s }, t: f.t, n: f.n };
  }
  if (u >= spine.length - 1) {
    const f = spine[spine.length - 1];
    const over = s - (spine.length - 1) * step;
    return { p: { x: f.p.x + f.t.x * over, y: f.p.y + f.t.y * over }, t: f.t, n: f.n };
  }
  const i = Math.floor(u);
  const k = u - i;
  const a = spine[i];
  const b = spine[i + 1];
  const tx = a.t.x + (b.t.x - a.t.x) * k;
  const ty = a.t.y + (b.t.y - a.t.y) * k;
  const length = Math.hypot(tx, ty) || 1;
  const t = { x: tx / length, y: ty / length };
  return {
    p: { x: a.p.x + (b.p.x - a.p.x) * k, y: a.p.y + (b.p.y - a.p.y) * k },
    t,
    n: { x: t.y, y: -t.x },
  };
}

/** Each outline point as (distance along the spine, offset along its tangent and its normal). */
type Bound = { s: number; along: number; across: number };
const DRAWN: Bound[] = BODY.map((point) => {
  let best = { s: 0, d: Infinity };
  for (let i = 0; i < COUNT - 1; i++) {
    const a = REST[i].p;
    const b = REST[i + 1].p;
    const dx = b.x - a.x;
    const dy = b.y - a.y;
    const k = ((point.x - a.x) * dx + (point.y - a.y) * dy) / (dx * dx + dy * dy);
    const c = Math.max(i === 0 ? -Infinity : 0, Math.min(i === COUNT - 2 ? Infinity : 1, k));
    const d = Math.hypot(point.x - (a.x + dx * c), point.y - (a.y + dy * c));
    if (d < best.d) best = { s: (i + c) * STEP, d };
  }
  const f = frameAt(REST, best.s);
  const vx = point.x - f.p.x;
  const vy = point.y - f.p.y;
  return { s: best.s, along: vx * f.t.x + vy * f.t.y, across: vx * f.n.x + vy * f.n.y };
});

/** How long the dragon is, from the neck to the tip of the tail. */
export const LENGTH = 1560;
/** Where the drawn body ends and the long, drawn-by-rule rest of it begins. */
const CUT = 548;

/**
 * The whole body's outline: the drawing up to CUT (the crest, the shoulder and the foreleg), then
 * the rest of a long body by rule, its crest spikes and its girth thinning to the tail.
 */
const BOUND: Bound[] = (() => {
  // The drawing's outline is one loop: along the crest to its cut end, back along the belly to
  // the throat, then up the crest again. Keep it up to CUT on both sides.
  const n = DRAWN.length;
  let throat = 0;
  DRAWN.forEach((b, i) => {
    if (b.s < DRAWN[throat].s) throat = i;
  });
  const first = DRAWN.findIndex((b) => b.s > CUT);
  let last = first;
  for (let i = first; i < throat; i++) if (DRAWN[i].s > CUT) last = i;
  const back: Bound[] = [];
  const girth = (s: number) => {
    const k = Math.min(1, Math.max(0, (s - 1180) / (LENGTH - 1180)));
    return 1 + 0.06 * Math.sin(Math.min(1, (s - CUT) / 500) * Math.PI) - 0.86 * k * k;
  };
  for (let s = CUT + 14; s < LENGTH - 20; s += 30) {
    const g = girth(s);
    const spike = 15 * g;
    back.push({ s, along: 0, across: 46 * g });
    back.push({ s: s + 9, along: 0, across: 46 * g + spike });
    back.push({ s: s + 12, along: 0, across: 44 * g });
  }
  const belly: Bound[] = [];
  for (let s = LENGTH - 30; s > CUT; s -= 36) belly.push({ s, along: 0, across: -52 * girth(s) });
  const tip: Bound = { s: LENGTH + 24, along: 0, across: -2 };
  const out: Bound[] = [];
  for (let i = last + 1; i < n; i++) out.push(DRAWN[i]);
  for (let i = 0; i < first; i++) out.push(DRAWN[i]);
  // `out` runs from the belly at CUT, along the belly to the neck, round the throat and back
  // along the crest to CUT; then the rule-drawn back, the tip and the belly close the loop.
  return [...out, ...back, tip, ...belly];
})();

/** The neck's point at rest, where the head hangs, and the spine's direction there. */
export const NECK = REST[0].p;
const NECK_ANGLE = Math.atan2(REST[0].t.y, REST[0].t.x);

/* ———————————————————————————— The dragon's path ———————————————————————————— */

/**
 * The line the dragon's head travels and its body follows, in the order the head takes it: in
 * from below the picture under the water, towards the boat; up through the surface right of the
 * bow, rising to the top right and curling over to the drawing's own neck line, so its last
 * stretch is the drawing's spine run backwards; then on, down into the water by the bow.
 */
const PATH = parse(`
  -260,905 -60,895 160,880 380,872 600,878 800,890 960,890 1080,872 1150,845 1196,800 1252,712
  1334,620 1424,520 1506,420 1566,318 1594,228 1578,150 1528,104 1450,90 1366,104 1294,132
  1228,190 1172,266 1120,340 1098,440 1084,560 1077,690 1074,800 1066,920 1046,1050 1012,1190
  975,1350
`);
const TRACK_STEP = 6;
const TRACK = (() => {
  const { points, marks } = resample(PATH, TRACK_STEP);
  return { frames: frames(points), marks };
})();
/** How far along the path the head is when it breaks the water, hangs, and dives. */
export const MARKS = {
  /** The neck where the body breaks the surface. */
  breach: TRACK.marks[9],
  /** The neck where the drawing has it: the head over the moon. */
  hang: TRACK.marks[23],
  /** The neck where it goes back under. */
  dive: TRACK.marks[27],
  end: TRACK.marks[TRACK.marks.length - 1],
};
/** The water line the dragon breaks, in canvas units: everything below it is under water. */
export const SURFACE = 800;
/**
 * Under the water the picture is foreshortened: depths below the surface are drawn this much
 * shorter, so the path can run under the boat and stay in the water's band.
 */
export const DEPTH = 0.5;
/** A point under the water, as the picture draws it. */
export const seen = (p: Point): Point =>
  p.y > SURFACE ? { x: p.x, y: SURFACE + (p.y - SURFACE) * DEPTH } : p;

const along = (h: number) => frameAt(TRACK.frames, h, TRACK_STEP).p;

export type DragonState = {
  /** How far along its path the neck is. */
  h: number;
  /** The ambient clock, in seconds: the waves and the breath. */
  time: number;
  /** How much the body swims (its waves): 0 still, 1 the hanging sway, 2 a swim. */
  swim: number;
  /** Moves the head (and, less and less, the neck behind it) by that many units. */
  lead: Point;
  /** Turns the head that many radians more. */
  turn: number;
  /** How far the whole dragon has sunk, in units. */
  sink: number;
  /**
   * How far the top of the body is lifted to the drawing's pose: 0 holds the crest low and in
   * from the right edge (so the head, passing there, stays in the picture), 1 is the drawing.
   */
  lift: number;
};

/** A point of the spine, `s` behind the neck, for a state. */
export function spinePoint({ h, time, swim, lead, sink, lift }: DragonState, s: number): Point {
  const low = 1 - lift;
  const p = along(h - s);
  const back = along(h - s - TRACK_STEP);
  const fwd = along(h - s + TRACK_STEP);
  const tx = back.x - fwd.x;
  const ty = back.y - fwd.y;
  const l = Math.hypot(tx, ty) || 1;
  // Waves run down the body from the head: calm at the neck, so the head keeps its own motion.
  const envelope = smooth(0, 160, s);
  const wave =
    swim *
    envelope *
    (11 * Math.sin((s / 430) * Math.PI * 2 - time * 1.35) +
      4 * Math.sin((s / 230) * Math.PI * 2 - time * 2.1 + 1.3));
  const pull = Math.exp(-s / 230);
  // Held low: the top of the path is pressed down, and its right side drawn in.
  const up = ease(400 - p.y, 80);
  const right = ease(p.x - 1280, 80);
  return {
    x: p.x + (ty / l) * wave + lead.x * pull - low * 0.85 * right,
    y: p.y - (tx / l) * wave + lead.y * pull + sink + low * 0.75 * up,
  };
}

/** The spine for a state: each point of the body sits on the path, behind the neck. */
function spineFor(state: DragonState): Frame[] {
  const count = Math.ceil((LENGTH + 40) / STEP) + 1;
  const points: Point[] = [];
  for (let i = 0; i < count; i++) points.push(spinePoint(state, i * STEP));
  return frames(points);
}

export type Pose = {
  /** The body's outline. */
  body: string;
  /** The head's transform, from its rest drawing. */
  head: string;
  /** The head's turn from rest, in radians, and its neck point. */
  angle: number;
  neck: Point;
  /** Where the body crosses the water line, left to right. */
  crossings: number[];
};

/** The dragon in a state: its body's outline, its head's transform, where it meets the water. */
export function pose(state: DragonState): Pose {
  const spine = spineFor(state);
  let body = '';
  for (let i = 0; i < BOUND.length; i++) {
    const { s, along: a, across } = BOUND[i];
    const f = frameAt(spine, s);
    const x = f.p.x + f.t.x * a + f.n.x * across;
    const y = f.p.y + f.t.y * a + f.n.y * across;
    body += `${i ? 'L' : 'M'}${x.toFixed(1)} ${y.toFixed(1)}`;
  }
  const neck = spine[0];
  const angle = Math.atan2(neck.t.y, neck.t.x) - NECK_ANGLE + state.turn;
  const breathe = 1 + 0.012 * Math.min(1, state.swim) * Math.sin(state.time * 1.25);
  const head =
    `translate(${neck.p.x.toFixed(2)} ${neck.p.y.toFixed(2)}) ` +
    `rotate(${((angle * 180) / Math.PI).toFixed(3)}) ` +
    `scale(${breathe.toFixed(4)}) translate(${-NECK.x} ${-NECK.y})`;
  const crossings: number[] = [];
  for (let i = 1; i < spine.length; i++) {
    const a = spine[i - 1].p;
    const b = spine[i].p;
    if (a.y < SURFACE !== b.y < SURFACE) {
      crossings.push(a.x + ((SURFACE - a.y) / (b.y - a.y)) * (b.x - a.x));
    }
  }
  return { body: body + 'Z', head, angle, neck: neck.p, crossings };
}

/** Where a point of the head's drawing is, for a pose. */
export function headPoint({ angle, neck }: Pose, p: Point): Point {
  const c = Math.cos(angle);
  const s = Math.sin(angle);
  const x = p.x - NECK.x;
  const y = p.y - NECK.y;
  return { x: neck.x + x * c - y * s, y: neck.y + x * s + y * c };
}
/** The tip of the snout, in the head's drawing. */
export const SNOUT: Point = { x: 958, y: 482 };

/** 0 below 0, then a soft knee `knee` wide, then a straight line: d - knee / 2. */
const ease = (d: number, knee: number) =>
  d <= 0 ? 0 : d < knee ? (d * d) / (2 * knee) : d - knee / 2;

export function smooth(a: number, b: number, x: number) {
  const k = Math.min(1, Math.max(0, (x - a) / (b - a)));
  return k * k * (3 - 2 * k);
}

/** The whiskers at time `t`: each sways more towards its tip. */
export function whiskers(t: number, amount = 1): string[] {
  return WHISKERS.map((points, w) =>
    smoothPath(
      points.map((p, i) => {
        const reach = i / (points.length - 1);
        const sway = amount * Math.sin(t * 1.6 - i * 0.55 + w * 1.7) * 7 * reach * reach;
        const lift = amount * Math.sin(t * 1.1 - i * 0.4 + w) * 3 * reach;
        return { x: p.x + lift, y: p.y + sway };
      }),
    ),
  );
}

/* ———————————————————————————— The rest of the scene ———————————————————————————— */

/** The moon: its middle sits on the horizon. */
export const MOON = { x: 803, y: 736, r: 344 };
export const HORIZON = 741;
export const CRATERS = [
  { x: 573, y: 521, r: 100 },
  { x: 760, y: 394, r: 40 },
  { x: 990, y: 468, r: 84 },
  { x: 856, y: 452, r: 12 },
];

export type Lump = { x: number; y: number; r: number };

export const BACK_CLOUDS: { lumps: Lump[]; base?: [number, number, number]; alpha?: number }[] = [
  { lumps: [{ x: 400, y: 725, r: 62 }], alpha: 0.5 },
  { lumps: [{ x: 290, y: 685, r: 75 }], alpha: 0.6 },
  {
    lumps: [
      { x: -10, y: 640, r: 60 },
      { x: 90, y: 650, r: 110 },
      { x: 195, y: 620, r: 80 },
    ],
    base: [-60, 268, 620],
  },
  { lumps: [{ x: 1165, y: 718, r: 50 }], alpha: 0.75 },
  { lumps: [{ x: 1252, y: 700, r: 52 }], alpha: 0.85 },
  {
    lumps: [
      { x: 1350, y: 690, r: 75 },
      { x: 1420, y: 680, r: 80 },
      { x: 1520, y: 700, r: 133 },
      { x: 1700, y: 700, r: 120 },
    ],
    base: [1300, 1700, 690],
  },
];

/** A cloud as one shape: its round lumps and a flat base down to the water. */
export const cloudPath = ({ lumps, base }: (typeof BACK_CLOUDS)[number]) =>
  lumps
    .map(
      ({ x, y, r }) =>
        `M${x - r} ${y}A${r} ${r} 0 1 1 ${x + r} ${y}A${r} ${r} 0 1 1 ${x - r} ${y}Z`,
    )
    .join('') + (base ? `M${base[0]} ${base[2]}H${base[1]}V${HORIZON + 2}H${base[0]}Z` : '');

/** The low cloud on the right, over the water, and the two near ones in front. */
export const MID_CLOUD =
  'M1132 893C1150 884 1190 880 1240 874C1250 852 1280 840 1312 842C1325 818 1360 808 1395 811C1430 808 1462 810 1484 826C1510 840 1530 852 1530 870C1528 884 1520 894 1505 896Z';
export const FRONT_LEFT =
  'M-60 1072L-60 1040L80 1038C92 1012 125 994 165 994C190 994 210 984 228 962C248 940 285 934 322 936C335 899 362 869 400 862C430 856 455 862 475 870C500 856 530 850 565 852C620 854 668 874 700 906C740 934 782 974 784 1022C786 1046 780 1072 770 1072Z';
export const FRONT_RIGHT =
  'M1000 1240L1010 1180C1028 1156 1064 1146 1088 1112C1104 1058 1150 1000 1214 996C1260 994 1292 1012 1318 1040C1342 1056 1374 1046 1420 1043C1480 1042 1530 1062 1570 1092C1620 1124 1660 1170 1680 1240Z';

/** The boat: a crescent hull, high at the bow. */
export const HULL =
  'M704 665C697 655 683 648 665 650C645 653 630 668 627 690C623 712 625 740 632 758C640 778 657 794 680 798L928 798C948 796 962 782 963 764C964 746 952 726 942 711C941 722 937 732 926 737L682 737C668 732 657 722 654 708C652 690 656 674 668 663C680 656 694 658 704 665Z';
/** The figure: a straw hat, a robe, one sword raised and one at the waist. */
export const HAT = 'M804 645L818 658L787 658Z';
export const FIGURE =
  'M798 657L811 657L812 668L820 672L849 672L855 676L852 682L838 685L826 690L820 697L826 705L838 712L846 720L849 728L851 741L773 741L784 730L790 718L792 700L791 680L794 668Z';
export const BLADE = 'M849.5 598L852.5 601L852.5 664L849.5 664Z';
export const GUARD = 'M846 664H857V671H846Z';
export const GRIP = 'M849.5 671H852.5V690H849.5Z';
export const SHEATH = 'M789 705L792 710L757 727L754 723Z';
export const LANTERN = { x: 692, y: 674, w: 37, h: 42, r: 9 };

/** Where the reflections fold: the boat's keel. */
export const KEEL = 797;

/**
 * The moon's reflection: rows of brush strokes, wide under the horizon and narrowing down, their
 * ends ragged (a fixed jitter), in three sets that shimmer out of step.
 */
const JITTER = [
  0.2, -0.7, 0.9, -0.3, 0.6, -0.9, 0.1, 0.8, -0.5, 0.4, -0.8, 0.7, -0.1, 0.5, -0.6, 0.3, -0.4, 0.9,
];
export const STROKES = JITTER.map((j, i) => {
  const y = 741 + i * 18;
  const k = i / (JITTER.length - 1);
  const left = 445 + 250 * k * k + 160 * k + j * 22;
  const right = 1160 - 110 * k - 90 * k * k + JITTER[(i + 5) % JITTER.length] * 26;
  return { x: left, y, w: right - left, h: 26 + (i % 3) * 4, layer: (i % 3) as 0 | 1 | 2 };
});

/**
 * The petals as the scene first shows them: middle, size (length × width, in canvas units),
 * angle, and how far from the eye they are (0 far, 1 middle, 2 near, blurred and quick).
 */
export type Petal = {
  x: number;
  y: number;
  l: number;
  w: number;
  a: number;
  depth: 0 | 1 | 2;
  blur?: number;
  dim?: number;
};
export const PETALS: Petal[] = [
  { x: 907, y: 88, l: 44, w: 23, a: 36, depth: 1 },
  { x: 753, y: 91, l: 46, w: 23, a: -38, depth: 1 },
  { x: 569, y: 136, l: 41, w: 22, a: 31, depth: 1 },
  { x: 802, y: 188, l: 43, w: 23, a: 33, depth: 1 },
  { x: 977, y: 243, l: 43, w: 23, a: 39, depth: 1 },
  { x: 586, y: 245, l: 39, w: 25, a: -76, depth: 1, blur: 3.5 },
  { x: 1209, y: 296, l: 40, w: 22, a: 27, depth: 1 },
  { x: 664, y: 316, l: 50, w: 23, a: -65, depth: 1 },
  { x: 1304, y: 349, l: 43, w: 23, a: 35, depth: 1 },
  { x: 1506, y: 448, l: 43, w: 23, a: -61, depth: 1 },
  { x: 1188, y: 516, l: 46, w: 24, a: -43, depth: 1 },
  { x: 568, y: 495, l: 42, w: 22, a: 32, depth: 1 },
  { x: 604, y: 626, l: 44, w: 22, a: -70, depth: 1 },
  { x: 705, y: 415, l: 42, w: 22, a: -60, depth: 1 },
  { x: 1126, y: 91, l: 22, w: 12, a: 35, depth: 0 },
  { x: 1521, y: 166, l: 18, w: 9, a: 22, depth: 0, dim: 0.6 },
  { x: 1321, y: 266, l: 26, w: 13, a: 4, depth: 0 },
  { x: 867, y: 268, l: 17, w: 10, a: 46, depth: 0 },
  { x: 1419, y: 391, l: 19, w: 10, a: 19, depth: 0 },
  { x: 1123, y: 417, l: 24, w: 14, a: 28, depth: 0 },
  { x: 235, y: 240, l: 46, w: 22, a: -78, depth: 2, blur: 6, dim: 0.75 },
  { x: 428, y: 355, l: 38, w: 18, a: -60, depth: 2, blur: 4, dim: 0.8 },
  { x: 350, y: 297, l: 28, w: 12, a: -80, depth: 2, blur: 3, dim: 0.8 },
  { x: 309, y: 457, l: 30, w: 14, a: -5, depth: 2, blur: 4, dim: 0.7 },
  { x: 125, y: 385, l: 16, w: 8, a: -60, depth: 2, blur: 2, dim: 0.8 },
  { x: 1258, y: 190, l: 30, w: 14, a: 20, depth: 2, blur: 4, dim: 0.35 },
];

/** More petals than the drawing has, scattered the same way (a fixed seed: no layout shift). */
export function petals(count: number): Petal[] {
  const out = PETALS.slice(0, Math.max(0, count));
  let seed = 7;
  const random = () => {
    seed = (seed * 16807) % 2147483647;
    return (seed - 1) / 2147483646;
  };
  while (out.length < count) {
    const depth = random() < 0.55 ? 1 : random() < 0.6 ? 0 : 2;
    const size =
      depth === 1 ? 40 + random() * 8 : depth === 0 ? 16 + random() * 10 : 30 + random() * 16;
    out.push({
      x: 60 + random() * 1480,
      y: 40 + random() * 560,
      l: size,
      w: size * 0.52,
      a: random() * 180 - 90,
      depth: depth as 0 | 1 | 2,
      blur: depth === 2 ? 3 + random() * 3 : undefined,
      dim: depth === 2 ? 0.75 : undefined,
    });
  }
  return out;
}

/** Specks of light: around the dragon's head, and by the boat. */
export const SPARKS: { x: number; y: number; r: number }[] = [
  { x: 952, y: 352, r: 2.5 },
  { x: 957, y: 347, r: 1.5 },
  { x: 952, y: 387, r: 2.5 },
  { x: 939, y: 399, r: 2.5 },
  { x: 954, y: 405, r: 3 },
  { x: 1163, y: 430, r: 2.5 },
  { x: 1170, y: 427, r: 1.5 },
  { x: 1090, y: 473, r: 3 },
  { x: 1107, y: 486, r: 3 },
  { x: 1087, y: 492, r: 3.5 },
  { x: 1136, y: 466, r: 2.5 },
  { x: 1151, y: 461, r: 2.5 },
  { x: 1146, y: 476, r: 3 },
  { x: 645, y: 792, r: 3 },
  { x: 638, y: 789, r: 1.5 },
  { x: 644, y: 811, r: 2 },
  { x: 835, y: 720, r: 1.5 },
  { x: 841, y: 732, r: 2 },
];
```

### moon-dragon/rig.ts

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

import type { Point } from './scene';

/*
 * The swordsman as a jointed rig, in the canvas's units, facing right. Hips carry a robe that
 * reaches the deck; the torso turns about the hips, the head (with its hat) about the neck; each
 * arm is two bones, solved each frame so the hands land where the sword wants them (two-bone
 * inverse kinematics); the sword is placed between two holds: raised upright in salute, or lying
 * level on both palms, before the chest or raised above the head to the dragon.
 *
 * Kneeling, the figure is the drawing's own: hips at (806, 712), its hat's tip at y 645, the sword
 * upright at x 851.
 */

export type Stance = {
  /** 0 kneeling, 1 standing. */
  rise: number;
  /** The torso's lean forward, in degrees. */
  lean: number;
  /** The head's tilt from the torso, in degrees: positive bows, negative looks up. */
  nod: number;
  /** 0 the sword raised upright, 1 held level on both palms. */
  offer: number;
  /** How high the level sword is held: 0 before the chest, 1 raised above the head. */
  raise: number;
};

export type Rig = {
  skirt: string;
  torso: string;
  head: string;
  sword: string;
  sheath: string;
  front: string;
  back: string;
  hands: [Point, Point];
};

const DECK = 741;
const KNEEL = { x: 806, y: 712 };
const STAND = { x: 808, y: 688 };
const UPPER = 19;
const FORE = 19;

/** The robe from the hips down to the deck, kneeling and standing, point for point. */
const SKIRT_KNEEL: Point[] = [
  { x: -10, y: -6 },
  { x: -33, y: 29 },
  { x: 45, y: 29 },
  { x: 43, y: 13 },
  { x: 30, y: -3 },
  { x: 12, y: -8 },
];
const SKIRT_STAND: Point[] = [
  { x: -10, y: -6 },
  { x: -17, y: 53 },
  { x: 19, y: 53 },
  { x: 15, y: 30 },
  { x: 12, y: 8 },
  { x: 10, y: -6 },
];

/** The torso, about the hips (y up is negative). */
export const TORSO =
  'M-11 -3C-12 -14 -12 -24 -10 -33C-9 -38 -6 -42 -2 -45L4 -44C8 -41 11 -35 12 -26C13 -18 13 -10 12 -3Z';
/** The head and the straw hat, about the neck. */
export const HEAD_SHAPE = 'M0 -12A6 6 0 1 1 0 0A6 6 0 1 1 0 -12Z';
export const HAT_SHAPE = 'M-17 -10L-1 -24L14 -10Z';
/** The sword, about its guard, the blade along +x. */
export const SWORD = {
  grip: 'M-19 -1.7H-1V1.7H-19Z',
  guard: 'M-1.5 -5.5H3V5.5H-1.5Z',
  blade: 'M3 -1.6H63L69 0L63 1.6H3Z',
};
const NECK: Point = { x: -1, y: -44 };
const SHOULDER_FRONT: Point = { x: 4, y: -40 };
const SHOULDER_BACK: Point = { x: -4, y: -39 };

const lerp = (a: number, b: number, k: number) => a + (b - a) * k;
const rad = (deg: number) => (deg * Math.PI) / 180;
const rotate = (p: Point, deg: number): Point => {
  const c = Math.cos(rad(deg));
  const s = Math.sin(rad(deg));
  return { x: p.x * c - p.y * s, y: p.x * s + p.y * c };
};
const add = (a: Point, b: Point): Point => ({ x: a.x + b.x, y: a.y + b.y });
const f = (n: number) => n.toFixed(2);

/** Two-bone inverse kinematics: the elbow and the hand for a shoulder reaching for `target`. */
function reach(shoulder: Point, target: Point): [Point, Point] {
  const dx = target.x - shoulder.x;
  const dy = target.y - shoulder.y;
  const d = Math.max(
    Math.abs(UPPER - FORE) + 0.5,
    Math.min(UPPER + FORE - 0.05, Math.hypot(dx, dy)),
  );
  const base = Math.atan2(dy, dx);
  const bend = Math.acos((UPPER * UPPER + d * d - FORE * FORE) / (2 * UPPER * d));
  const a = { x: Math.cos(base + bend), y: Math.sin(base + bend) };
  const b = { x: Math.cos(base - bend), y: Math.sin(base - bend) };
  // The elbow hangs below the line to the hand.
  const way = a.y > b.y ? a : b;
  const elbow = { x: shoulder.x + way.x * UPPER, y: shoulder.y + way.y * UPPER };
  const hand = { x: shoulder.x + Math.cos(base) * d, y: shoulder.y + Math.sin(base) * d };
  return [elbow, hand];
}

export function rig({ rise, lean, nod, offer, raise }: Stance): Rig {
  const hip = { x: lerp(KNEEL.x, STAND.x, rise), y: lerp(KNEEL.y, STAND.y, rise) };
  const skirt = SKIRT_KNEEL.map((p, i) => {
    const q = SKIRT_STAND[i];
    const point = { x: hip.x + lerp(p.x, q.x, rise), y: hip.y + lerp(p.y, q.y, rise) };
    // The hem stays on the deck.
    if (i === 1 || i === 2) point.y = DECK;
    return point;
  });
  const world = (p: Point) => add(hip, rotate(p, lean));
  const shoulderFront = world(SHOULDER_FRONT);
  const shoulderBack = world(SHOULDER_BACK);

  // The sword's guard and angle: upright before the chest, or level and held out.
  const k = offer * offer * (3 - 2 * offer);
  const salute = world({ x: 43, y: -44 });
  const r = raise * raise * (3 - 2 * raise);
  const level = add(shoulderFront, { x: lerp(8, 2, r), y: lerp(18, -30, r) });
  const guard = { x: lerp(salute.x, level.x, k), y: lerp(salute.y, level.y, k) };
  const angle = lerp(lean - 90, 0, k);
  // Where the hands hold it, along the sword (x) and under it (y): both on the grip, upright;
  // one under the grip and one under the blade, level.
  const holdFront = { x: lerp(-6, 13, k), y: lerp(0, 3.6, k) };
  const holdBack = { x: lerp(-15, -11, k), y: lerp(0, 3.6, k) };
  const onSword = (p: Point) => add(guard, rotate(p, angle));
  const [elbowFront, handFront] = reach(shoulderFront, onSword(holdFront));
  const [elbowBack, handBack] = reach(shoulderBack, onSword(holdBack));
  const arm = (s: Point, e: Point, h: Point) =>
    `M${f(s.x)} ${f(s.y)}L${f(e.x)} ${f(e.y)}L${f(h.x)} ${f(h.y)}`;

  return {
    skirt: 'M' + skirt.map((p) => `${f(p.x)} ${f(p.y)}`).join('L') + 'Z',
    torso: `translate(${f(hip.x)} ${f(hip.y)}) rotate(${f(lean)})`,
    head: `translate(${NECK.x} ${NECK.y}) rotate(${f(nod)})`,
    sword: `translate(${f(guard.x)} ${f(guard.y)}) rotate(${f(angle)})`,
    sheath: `translate(${f(hip.x - KNEEL.x)} ${f(hip.y - KNEEL.y)})`,
    front: arm(shoulderFront, elbowFront, handFront),
    back: arm(shoulderBack, elbowBack, handBack),
    hands: [handFront, handBack],
  };
}
```

### moon-dragon/use-autoplay.ts

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

'use client';

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

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

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

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

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

### moon-dragon/moon-dragon.module.css

```css
/* Moon dragon (moon-dragon.tsx): a 4:3 night scene in layers. Every layer is the same canvas
   (1600 × 1200 units, drawn 6% larger so it can slide), and --u is one canvas unit in px of the
   stage (a container query unit), so HTML pieces, motion and blur all scale with the stage.

   The scene's colours are the palette's (data-palette); they are the same on light and dark
   pages, since the picture is a night. Only the frame and the pause button follow the colour
   scheme: light-dark() tokens, from the page's color-scheme or the colorScheme prop.

   The story's moving parts are set by script each frame, by transform and opacity where they
   can be (the moon, its halo and rings, the light on the water, the sparks); the dragon, the
   water's marks and the swordsman's rig are small SVG attributes.

   Memory. The stage is a layout boundary sized from outside; each layer is its own compositor
   layer, moved by transform and opacity only. Only the dragon's SVG repaints while it plays. */

.root {
  /* The palette: violet, the default. */
  --sky: #210f38;
  --halo-1: #5a4b69;
  --halo-2: #3f2e52;
  --halo-3: #36224a;
  --halo-4: #2c1843;
  --pulse: rgb(206 186 236 / 0.22);
  --moon: #fbf2e1;
  --crater: #ece0c6;
  --moon-mist: #f4e5e1;
  --cloud-top: #7a6890;
  --cloud-bottom: #302045;
  --water: #1e0434;
  --pool-1: #2e1442;
  --pool-2: #2a0f3e;
  --mirror-top: #231035;
  --mirror-bottom: #3b214e;
  --glint-1: #ab9fb2;
  --glint-2: #8a7699;
  --glint-3: #5f4b76;
  --hull-1: #7c6069;
  --hull-2: #45224f;
  --hull-3: #341448;
  --figure: #22092f;
  --lantern: #fdcc02;
  --lantern-glow: rgb(255 214 40 / 0.5);
  --petal: #b600fd;
  --petal-alpha: 0.66;
  --dragon-1: #31005c;
  --dragon-2: #220143;
  --dragon-3: #0e0118;
  --dragon-4: #000;
  --eye: #fefefe;
  --eye-glow: rgb(214 196 255 / 0.3);
  --whisker: #0d0612;
  --mid-cloud: #776587;
  --near-cloud: #c4bccd;
  --spark: #fff;
  --spray: #efe6fb;
  --swell: rgb(214 196 240 / 0.55);
  --flare: rgb(236 222 255 / 0.5);
  --ring: rgb(255 244 228 / 0.55);
  --bloom: #fff;
  --bloom-glow: rgb(255 255 255 / 0.5);
  /* The frame and the controls. */
  --line: rgb(0 0 0 / 0.08);
  --control: rgb(255 255 255 / 0.82);
  --control-ink: #1d1426;
  --chapter-ink: rgb(255 255 255 / 0.62);
  --chapter-current: #fff;
  --focus: #387bff;
  --u: calc(100cqw / 1600);
  --mx: 0;
  --my: 0;
  position: relative;
  container-type: inline-size;
  contain: layout paint;
  isolation: isolate;
  width: 100%;
  aspect-ratio: 4 / 3;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--sky);
  touch-action: pan-y;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --line: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.1));
    --control: light-dark(rgb(255 255 255 / 0.82), rgb(24 14 36 / 0.72));
    --control-ink: light-dark(#1d1426, #f2ecfa);
    --focus: light-dark(#387bff, #6ea0ff);
  }
}
.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;
}

/* Two other nights: warm, and sea-green. */
.root[data-palette='ember'] {
  --sky: #240c1b;
  --halo-1: #6e4b5a;
  --halo-2: #4d2c3d;
  --halo-3: #3d1f31;
  --halo-4: #2e1424;
  --pulse: rgb(255 196 170 / 0.22);
  --moon: #fde9cf;
  --crater: #f1d6b4;
  --moon-mist: #f6e0d6;
  --cloud-top: #8d6474;
  --cloud-bottom: #3a1c2c;
  --water: #1f0614;
  --pool-1: #33152a;
  --pool-2: #2c1023;
  --mirror-top: #2a0f20;
  --mirror-bottom: #452234;
  --glint-1: #c6a3a6;
  --glint-2: #a07686;
  --glint-3: #6c4659;
  --hull-1: #8f6158;
  --hull-2: #522434;
  --hull-3: #3d1426;
  --figure: #2a0a18;
  --lantern: #ffb000;
  --lantern-glow: rgb(255 210 140 / 0.75);
  --petal: #ff3d6e;
  --petal-alpha: 0.72;
  --dragon-1: #5a0a24;
  --dragon-2: #3a0618;
  --dragon-3: #14020a;
  --eye-glow: rgb(255 200 190 / 0.3);
  --whisker: #120408;
  --mid-cloud: #8a6070;
  --near-cloud: #d9c0c4;
}
.root[data-palette='tide'] {
  --sky: #08172a;
  --halo-1: #4d6577;
  --halo-2: #33495d;
  --halo-3: #253a4f;
  --halo-4: #172a3f;
  --pulse: rgb(180 226 240 / 0.2);
  --moon: #eef6ef;
  --crater: #d9e6dc;
  --moon-mist: #e2ece8;
  --cloud-top: #5f7c8e;
  --cloud-bottom: #1f344a;
  --water: #061426;
  --pool-1: #10283f;
  --pool-2: #0c2238;
  --mirror-top: #0d2136;
  --mirror-bottom: #20384f;
  --glint-1: #9eb6bf;
  --glint-2: #75909f;
  --glint-3: #46607a;
  --hull-1: #5f7880;
  --hull-2: #23394d;
  --hull-3: #142a3e;
  --figure: #081a2b;
  --lantern: #ffd84a;
  --petal: #22d3c5;
  --petal-alpha: 0.72;
  --dragon-1: #003f52;
  --dragon-2: #022b3c;
  --dragon-3: #010f17;
  --eye-glow: rgb(190 240 255 / 0.3);
  --whisker: #02080d;
  --mid-cloud: #587487;
  --near-cloud: #b6c8d1;
}

/* The frame's edge, over the picture. */
.root::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: inset 0 0 0 1px var(--line);
  pointer-events: none;
}

.scene {
  position: absolute;
  inset: 0;
}

/* A layer: the canvas, 6% larger than the stage, slid by the pointer as far as its depth. */
.layer {
  position: absolute;
  left: -3%;
  top: -3%;
  display: block;
  width: 106%;
  height: 106%;
  overflow: visible;
  pointer-events: none;
  translate: calc(var(--mx) * var(--depth, 0) * var(--u) * -22)
    calc(var(--my) * var(--depth, 0) * var(--u) * -14);
}
.fill {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
}

/* Colours in the drawings. */
.sky {
  fill: var(--sky);
}
.stopSky {
  stop-color: var(--sky);
}
.stopHalo1 {
  stop-color: var(--halo-1);
}
.stopHalo2 {
  stop-color: var(--halo-2);
}
.stopHalo3 {
  stop-color: var(--halo-3);
}
.stopHalo4 {
  stop-color: var(--halo-4);
}
.moon {
  fill: var(--moon);
}
.crater {
  fill: var(--crater);
}
.moonMist {
  fill: var(--moon-mist);
}
.stopCloudTop {
  stop-color: var(--cloud-top);
}
.stopCloudBottom {
  stop-color: var(--cloud-bottom);
}
.stopDragon1 {
  stop-color: var(--dragon-1);
}
.stopDragon2 {
  stop-color: var(--dragon-2);
}
.stopDragon3 {
  stop-color: var(--dragon-3);
}
.stopDragon4 {
  stop-color: var(--dragon-4);
}
.stopEyeGlow {
  stop-color: var(--eye-glow);
}
.stopEyeGlowEnd {
  stop-color: var(--eye-glow);
  stop-opacity: 0;
}
.eye {
  fill: var(--eye);
  animation: glint calc(5.5s / var(--speed)) ease-in-out infinite;
  transform-box: fill-box;
  transform-origin: center;
}
.eyeGlow {
  animation: eye-glow calc(4.2s / var(--speed)) ease-in-out infinite;
}
.whisker {
  fill: none;
  stroke: var(--whisker);
  stroke-width: 4;
  stroke-linecap: round;
}
.water {
  fill: var(--water);
}
.stopPool1 {
  stop-color: var(--pool-1);
}
.stopPool2 {
  stop-color: var(--pool-2);
}
.stopMirrorTop {
  stop-color: var(--mirror-top);
}
.stopMirrorBottom {
  stop-color: var(--mirror-bottom);
}
.stopGlint1 {
  stop-color: var(--glint-1);
}
.stopGlint2 {
  stop-color: var(--glint-2);
}
.stopGlint3 {
  stop-color: var(--glint-3);
}
.stopHull1 {
  stop-color: var(--hull-1);
}
.stopHull2 {
  stop-color: var(--hull-2);
}
.stopHull3 {
  stop-color: var(--hull-3);
}
.figure {
  fill: var(--figure);
}
.mirrored {
  fill: var(--figure);
  opacity: 0.32;
}
.lantern {
  fill: var(--lantern);
}
.shade {
  fill: var(--water);
  opacity: 0.38;
}
.midCloud {
  fill: var(--mid-cloud);
}
.nearCloud {
  fill: var(--near-cloud);
  opacity: 0.9;
}

/* The moon's glow breathes: a soft light over the halo, from nothing to a little. */
.glow {
  background: radial-gradient(
    circle at 50.18% 60.69%,
    var(--pulse) 0,
    var(--pulse) 20%,
    transparent 36%
  );
  opacity: 0;
  animation: pulse calc(6s / var(--speed)) ease-in-out infinite;
}

/* The clouds drift, the nearer the further. */
.driftFar {
  --drift: calc(var(--u) * 18);
  animation: drift calc(34s / var(--speed)) ease-in-out infinite alternate;
}
.driftMid {
  --drift: calc(var(--u) * -30);
  animation: drift calc(23s / var(--speed)) ease-in-out infinite alternate;
}
.driftNear {
  --drift: calc(var(--u) * 46);
  animation: drift calc(27s / var(--speed)) ease-in-out infinite alternate;
}

/* The water: three sets of strokes sway and stretch out of step. */
.shimmer {
  transform-origin: 50.18% 50%;
  animation: shimmer calc(5.3s / var(--speed)) ease-in-out infinite alternate;
}
.shimmer[data-layer='1'] {
  --shift: -1;
  animation-duration: calc(6.7s / var(--speed));
}
.shimmer[data-layer='2'] {
  animation-duration: calc(4.4s / var(--speed));
}

/* The boat bobs about its keel; its reflection does the same upside down, and ripples. */
.boat {
  transform-origin: 49.7% 64.9%;
  animation: bob calc(4.8s / var(--speed)) ease-in-out infinite;
}
.boatReflection {
  transform-origin: 49.7% 65.5%;
  animation: reflect calc(4.8s / var(--speed)) ease-in-out infinite;
}
.lanternGlow {
  position: absolute;
  left: 44.72%;
  top: 57.47%;
  width: calc(var(--u) * 150);
  height: calc(var(--u) * 150);
  margin: calc(var(--u) * -75) 0 0 calc(var(--u) * -75);
  border-radius: 50%;
  background: radial-gradient(circle, var(--lantern-glow) 0, transparent 60%);
  animation: flicker calc(2.3s / var(--speed)) linear infinite;
}

.spark {
  position: absolute;
  width: calc(var(--u) * var(--r) * 2);
  height: calc(var(--u) * var(--r) * 2);
  margin: calc(var(--u) * var(--r) * -1) 0 0 calc(var(--u) * var(--r) * -1);
  border-radius: 50%;
  background: var(--spark);
  box-shadow: 0 0 calc(var(--u) * 7) calc(var(--u) * 2) rgb(255 255 255 / 0.45);
  animation: twinkle ease-in-out infinite;
}

/* Petals: each falls (and is carried left), sways, and tumbles. */
.petals {
  position: absolute;
  inset: 0;
  pointer-events: none;
  translate: calc(var(--mx) * var(--depth, 0) * var(--u) * -22)
    calc(var(--my) * var(--depth, 0) * var(--u) * -14);
}
.petal {
  position: absolute;
  width: 0;
  height: 0;
  animation: fall linear infinite;
}
.sway {
  position: absolute;
  animation: sway ease-in-out infinite;
}
.leaf {
  position: absolute;
  left: 0;
  top: 0;
  display: block;
  overflow: visible;
  translate: -50% -50%;
  transform: rotate(var(--a));
  filter: blur(var(--blur));
  animation: tumble linear infinite;
}

/* The controls: play or pause, the chapters, and Summon, on a scrim along the bottom. */
.controls {
  position: absolute;
  inset: auto 0 0;
  z-index: 2;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 18px 12px 10px;
  background: linear-gradient(to top, rgb(8 2 16 / 0.62), rgb(8 2 16 / 0));
  pointer-events: none;
}
.controls > * {
  pointer-events: auto;
}
.toggle {
  flex: none;
  display: grid;
  place-items: center;
  width: 32px;
  height: 32px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  color: var(--control-ink);
  background: var(--control);
  cursor: pointer;
}
.toggle svg,
.summon svg {
  width: 16px;
  height: 16px;
  fill: currentColor;
}
.toggle:focus-visible,
.summon:focus-visible,
.chapter:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.chapters {
  display: flex;
  flex: 1;
  min-width: 0;
  gap: 4px;
}
.chapter {
  display: flex;
  flex: 1 1 0;
  min-width: 0;
  flex-direction: column;
  justify-content: flex-end;
  gap: 5px;
  height: 32px;
  padding: 0 2px 6px;
  border: 0;
  border-radius: 6px;
  color: var(--chapter-ink);
  background: none;
  font:
    600 11px/1 system-ui,
    sans-serif;
  letter-spacing: 0.02em;
  text-align: left;
  cursor: pointer;
  transition:
    flex-grow 0.4s ease,
    color 0.2s ease;
}
.chapter:hover,
.chapter[aria-current] {
  color: var(--chapter-current);
}
.chapter[aria-current] {
  flex-grow: 1.6;
}
.chapterLabel {
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.track {
  position: relative;
  display: block;
  height: 3px;
  overflow: hidden;
  border-radius: 2px;
  background: rgb(255 255 255 / 0.22);
}
.progress {
  position: absolute;
  inset: 0;
  transform-origin: left;
  background: rgb(255 255 255 / 0.85);
  will-change: transform;
}
.summon {
  flex: none;
  display: flex;
  align-items: center;
  gap: 6px;
  height: 32px;
  padding: 0 12px 0 10px;
  border: 0;
  border-radius: 16px;
  color: var(--control-ink);
  background: var(--control);
  font:
    600 12px/1 system-ui,
    sans-serif;
  cursor: pointer;
}
/* Narrow: only the current chapter keeps its name, and Summon its moon. */
@container (max-width: 520px) {
  .controls {
    gap: 6px;
    padding: 14px 8px 8px;
  }
  .chapter:not([aria-current]) .chapterLabel {
    visibility: hidden;
  }
  .chapter[aria-current] {
    flex-grow: 3;
  }
  .summon {
    width: 32px;
    padding: 0;
    justify-content: center;
  }
  .summon span {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
}
.live {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* The moon is a button too, for the pointer: its upper half, over the horizon. */
.moonHit {
  position: absolute;
  left: 28.69%;
  top: 32.67%;
  z-index: 1;
  width: 43%;
  height: 29.08%;
  padding: 0;
  border: 0;
  border-radius: 50% 50% 0 0 / 100% 100% 0 0;
  background: none;
  cursor: pointer;
}

/* A soft cut when the reader jumps to a chapter. */
.cut {
  position: absolute;
  inset: 0;
  background: #000;
  opacity: 0;
  pointer-events: none;
}

/* The story's lights: scaled and faded by script. */
.swell {
  transform-origin: 50.18% 60.69%;
  will-change: transform;
}
.flare {
  background: radial-gradient(
    circle at 50.18% 60.69%,
    var(--flare) 0,
    var(--flare) 21%,
    transparent 38%
  );
  will-change: opacity;
}
.sheen {
  background: radial-gradient(ellipse 34% 22% at 50.18% 61.3%, var(--flare), transparent 100%);
  will-change: opacity;
}
.ringClip {
  position: absolute;
  inset: 0 0 auto;
  height: 61.08%;
  overflow: hidden;
}
.ring {
  position: absolute;
  left: 50.18%;
  top: 99.36%;
  width: calc(var(--u) * 700);
  height: calc(var(--u) * 700);
  margin: calc(var(--u) * -350) 0 0 calc(var(--u) * -350);
  border: calc(var(--u) * 3) solid var(--ring);
  border-radius: 50%;
  box-shadow:
    0 0 calc(var(--u) * 26) var(--ring),
    inset 0 0 calc(var(--u) * 26) var(--ring);
  will-change: transform, opacity;
}
.under {
  will-change: opacity;
}
.stopSwell {
  stop-color: var(--swell);
}
.stopSwellEnd {
  stop-color: var(--swell);
  stop-opacity: 0;
}
.ripples {
  fill: none;
  stroke: var(--spray);
  stroke-width: 2.5;
}
.drops {
  fill: none;
  stroke: var(--spray);
  stroke-width: 3.2;
  stroke-linecap: round;
  opacity: 0.85;
}
.bubbles {
  fill: var(--spray);
  opacity: 0.8;
}
.limb {
  fill: none;
  stroke: var(--figure);
  stroke-width: 5.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.group {
  position: absolute;
  inset: 0;
  will-change: opacity;
}
.bloomSparks {
  transform-origin: 66% 30%;
}

/* The petals turn white in a wave from the dragon's head, and glow. */
.tint {
  fill: var(--petal);
  fill-opacity: var(--petal-alpha);
  transition: opacity calc(1.5s / var(--speed)) ease calc(var(--wave) * 1.8s / var(--speed));
}
.white {
  fill: var(--bloom);
  fill-opacity: 0.94;
  opacity: 0;
  transition: opacity calc(1.5s / var(--speed)) ease calc(var(--wave) * 1.8s / var(--speed));
}
.halo {
  position: absolute;
  translate: -50% -50%;
  border-radius: 50%;
  background: radial-gradient(circle, var(--bloom-glow), transparent 62%);
  opacity: 0;
  transition: opacity calc(1.8s / var(--speed)) ease calc(var(--wave) * 1.8s / var(--speed));
}
.root[data-bloom] .white,
.root[data-bloom] .halo {
  opacity: 1;
}
.root[data-bloom] .tint {
  opacity: 0;
}

/* Offscreen, in a hidden tab or paused: everything holds. Reduced motion: the still picture. */
.root:not([data-playing]) * {
  animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
  .root * {
    animation: none !important;
    transition: none !important;
  }
}

@keyframes drift {
  from {
    transform: translateX(0);
  }
  to {
    transform: translateX(var(--drift));
  }
}
@keyframes shimmer {
  from {
    transform: translateX(0) scaleX(1);
  }
  to {
    transform: translateX(calc(var(--u) * 12 * var(--shift, 1))) scaleX(1.025);
  }
}
@keyframes bob {
  0%,
  100% {
    transform: translateY(0) rotate(0);
  }
  25% {
    transform: translateY(calc(var(--u) * 3.5)) rotate(0.9deg);
  }
  75% {
    transform: translateY(calc(var(--u) * -2.5)) rotate(-0.9deg);
  }
}
@keyframes reflect {
  0%,
  100% {
    transform: translateY(0) rotate(0) scaleY(1) skewX(0);
  }
  25% {
    transform: translateY(calc(var(--u) * -3.5)) rotate(-0.9deg) scaleY(1.05) skewX(2deg);
  }
  75% {
    transform: translateY(calc(var(--u) * 2.5)) rotate(0.9deg) scaleY(0.96) skewX(-2deg);
  }
}
@keyframes flicker {
  0%,
  100% {
    opacity: 1;
    transform: scale(1);
  }
  18% {
    opacity: 0.82;
    transform: scale(0.96);
  }
  31% {
    opacity: 0.95;
    transform: scale(1.02);
  }
  47% {
    opacity: 0.78;
    transform: scale(0.95);
  }
  66% {
    opacity: 1;
    transform: scale(1.04);
  }
  83% {
    opacity: 0.88;
    transform: scale(0.98);
  }
}
@keyframes pulse {
  0%,
  100% {
    opacity: 0;
  }
  50% {
    opacity: 1;
  }
}
@keyframes glint {
  0%,
  78%,
  100% {
    opacity: 1;
    transform: scale(1);
  }
  86% {
    opacity: 0.9;
    transform: scale(1.12);
  }
  92% {
    opacity: 1;
    transform: scale(1, 0.25);
  }
}
@keyframes eye-glow {
  0%,
  100% {
    opacity: 1;
  }
  50% {
    opacity: 0.65;
  }
}
@keyframes twinkle {
  0%,
  100% {
    opacity: 1;
    transform: scale(1);
  }
  50% {
    opacity: 0.3;
    transform: scale(0.55);
  }
}
@keyframes fall {
  from {
    transform: translate(var(--from-x), var(--from-y));
  }
  to {
    transform: translate(var(--to-x), var(--to-y));
  }
}
@keyframes sway {
  0%,
  100% {
    transform: translateX(0);
  }
  25% {
    transform: translateX(var(--sway));
  }
  75% {
    transform: translateX(calc(var(--sway) * -1));
  }
}
@keyframes tumble {
  0% {
    transform: rotate(var(--a)) rotate3d(1, 0.35, 0, 0deg);
  }
  50% {
    transform: rotate(calc(var(--a) + 180deg)) rotate3d(1, 0.35, 0, 62deg);
  }
  100% {
    transform: rotate(calc(var(--a) + 360deg)) rotate3d(1, 0.35, 0, 0deg);
  }
}
```