# Spirit dragon

> A painted dusk forest where a glowing white dragon swims between the trees, in front of some and behind others.

A teal forest at dusk, painted in SVG: a translucent spirit dragon holds its pose between the trees and swims in place, its ribbon body undulating, its long mane, whiskers, crest and feathered tail fan streaming, while a child holds up a flickering lantern, the reeds bend in the wind and a small fox flicks its tail. Paper grain over everything keeps it looking painted.

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

Category: Motion.

## Anatomy

1. **Forest**: The layer behind the dragon: sky, far trees and hills, the trees and the ground. Still SVG, painted once.
2. **Dragon body**: One path rebuilt every frame from the painting’s pose: the spine slides a little along it and a wave runs down the body.
3. **Head**: A rigid drawing (horns, jaw, a glowing eye, crest and whiskers) carried by one transform at the spine’s front.
4. **Tail fan**: Blades and ribs at the tail’s end, streaming on a clock of their own.
5. **Child**: A child in a red hoodie, head tipped back to look up, hair and hood streaming in the wind.
6. **Lantern**: Held out at arm’s length; it swings a little and its glow flickers, pooling on the ground below.
7. **Fox**: A small fox at the child’s feet, on the front layer with the reeds and the near tree, which also redraws the trees the dragon passes behind.
8. **Play button**: Pauses or plays the scene; its label says which. The reader’s choice wins over `autoplay` and reduced motion.

## When to use

- A hero or a splash screen that sets a mood before any words: a story, a game, 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 that should follow the page between day and dusk: the painting takes the page’s color-scheme.
- A scene that has to stay sharp at any size: every piece is vector, cropped to a 4:3 stage and scaled by its width.

## When not to use

- Behind text that has to be read: the trees, the dragon and the mist fight the words. Use a flat or blurred backdrop.
- A small thumbnail or an icon: under about 280 px the child and the fox turn to specks. Use a still illustration.
- A dense working screen (a dashboard, a form): the swimming pulls the eye away. Use a static header image.
- A scene the reader should step through or play with: this one only swims in place. Use the moon dragon, with its chapters.
- Several scenes on one screen at once: each redraws its dragon every frame. Show one live, the rest with `autoplay={false}`.

## Installation

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

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

export function Hero() {
  return <SpiritDragon speed={0.8} glow={0.9} />;
}
```

## States

- **Still frame**: `autoplay={false}`: the painting’s own pose, everything paused, until the play button is pressed. Reduced motion opens here too.
- **Playing**: The default: it swims in place, sheds motes and sways while on screen in a visible tab.
- **No glow**: `glow={0}`: no halo; the dragon is a pale shape against the trees.
- **Soft glow**: `glow={0.35}`: a faint halo, half the default.
- **Full glow**: `glow={1}`: the brightest halo, the body haloed in light.
- **Light (day)**: `colorScheme="light"`: a misty grey-pink sky over teal trees and hills.
- **Dark (dusk)**: `colorScheme="dark"`: a violet dusk, the trees deeper, the dragon and the lantern brighter against them.

## How it works

- The picture is four stacked layers in one 2000 × 1500 frame, cropped to the stage: the forest behind the dragon, the dragon, the forest in front of it, and a paper grain (fractal noise, blended in overlay). Only the dragon’s layer is drawn again on each frame; the rest are still SVG painted once.
- Every trunk, bough and twig is a middle line with a width at each point, turned into a tapering outline with a smooth spline. Its shaded side, light and dark brush streaks, warm rust streaks and dark knot holes are laid along that same line, so they follow the grain without a clip path; the bare twigs that cross the sky grow from a few roots, forking twice. The hills carry loose brush sweeps and a pale rim, each clipped to its hill.
- The dragon’s pose is a centripetal Catmull-Rom spline through hand-placed points, from the tip of the tail to the back of the head, each with the body’s width there. It never leaves it: the spine slides a few units back and forth along it, a wave runs down the body from neck to tail, and the whole dragon drifts a little, so it swims in place.
- Each frame rebuilds the body from 281 spine points. The ribbon’s width breathes a little, as if it twisted, with a soft halo and bright rims along its edges; the scales are staggered rows of arcs fixed to spine points, so they ride the wave. The mane is a pale mass lying back over the neck with strands drawn over it, each strand following the spine further down and flaring off it; the tail ends in a fan of six blades spread like a wing’s feathers, a membrane between them. The head, with its crest of soft spikes, its ruff, glowing eye and whiskers, is one drawing turned to the neck.
- Depth: where the dragon passes behind (the band across the middle, under the navy trunk and the teal boughs), the trees are drawn a second time over it, clipped to that part of the picture. So it weaves in front of one trunk and behind the next with no masks to recompute.
- The rest moves by CSS: the lantern swings and flickers (its glow, the glass and the pool of light on the ground), the hair and hood stream in the wind, the reeds sway, the fox flicks its tail every few seconds, mist drifts across and motes float; the dragon also sheds motes of its own that drift up and fade.
- It plays only while on screen in a visible tab; every animation then holds still. Under reduced motion it shows the still frame until the reader presses play.
- Every colour is a light-dark() token: by day a misty grey-pink sky over teal trees, at night a violet dusk with deeper trees and a brighter glow. Chrome does not repaint SVG painted from such tokens when only the colour scheme changes, so a hidden probe’s light-dark() colour is read again whenever an attribute on the component or any of its ancestors changes, or the system setting does, and on a new scheme the layers are drawn anew.

## API reference

- **autoplay** (`boolean`, default `true`): Swims on its own while on screen. With false it shows the still frame until the play button is pressed.
- **speed** (`number`, default `1`): How fast the dragon swims and everything sways: 1 is the painting’s own pace.
- **glow** (`number`, default `0.7`): How brightly the dragon glows, from 0 (no halo) to 1.
- **colorScheme** (`'light' | 'dark' | 'system'`): Forces day or dusk; without it, the scene follows the page’s color-scheme.
- **className** (`string`): Added to the root element.

## Accessibility

- The picture is one image (role="img") with a description of the scene; its layers are hidden from assistive technology.
- A play and pause button sits in its corner, reachable by keyboard, with a visible focus ring and a label that says what it will do.
- Under reduced motion it stays on the still frame until the reader chooses to play it; off screen or in a hidden tab, everything stops.

## Source

### spirit-dragon/spirit-dragon.tsx

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

'use client';

import {
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type RefObject,
} from 'react';
import { drawDragon, HEAD_DETAIL, HEAD_EYE, HEAD_SHAPE, type Frame } from './dragon';
import { BackScene, FrontScene, MOTES } from './scene';
import { useAutoplay } from './use-autoplay';
import styles from './spirit-dragon.module.css';

/*
 * Spirit dragon: a painted teal forest at dusk, where a translucent white dragon swims between the
 * trees above a child holding up a lantern, and a small fox.
 *
 * - The picture is four stacked layers in the same 2000 × 1500 frame, cropped to the stage: the
 *   painting behind the dragon (sky, trees, hills), the dragon, the painting in front of it, and a
 *   grain of paper over everything. Only the dragon's layer is drawn again on each frame; the rest
 *   are still SVG, and their few moving parts are CSS transforms.
 * - The dragon holds the painting's pose and swims in place: each frame rebuilds it from that pose
 *   (dragon.ts), its spine sliding a little back and forth along it, a wave running down the body,
 *   the mane, whiskers, crest and tail fan streaming on their own clocks.
 * - Depth: it passes in front of some trees and behind others. Where it is meant to be behind,
 *   the trees are drawn a second time over it, clipped to those parts of the picture (scene.tsx).
 * - Around it: the lantern flickers and swings, the child's hair and hood stream in the wind,
 *   the reeds sway, the fox flicks its tail, mist drifts, motes float, and the dragon sheds motes
 *   of its own that drift off and fade.
 * - It plays only while on screen in a visible tab. Under reduced motion it stays on the still
 *   frame until the reader presses play; the button pauses and plays it at any time.
 */

/** Motes the dragon sheds, each living a few seconds. */
const SHED = 16;

type Mote = { x: number; y: number; vx: number; vy: number; age: number; life: number };
type Part = keyof Omit<Frame, 'spine' | 'head'>;

/**
 * The colour scheme the component is drawn in, as the page or its colorScheme prop resolves it:
 * a hidden probe's colour is a light-dark() token, read again whenever something that can change
 * the scheme changes (an attribute on the root or any of its ancestors, or the system setting).
 * Chrome does not repaint SVG painted from light-dark() tokens when only the scheme changes, so
 * the layers are drawn anew, keyed on it.
 */
function useScheme(probe: RefObject<HTMLSpanElement | null>) {
  const [scheme, setScheme] = useState<'light' | 'dark'>('light');
  useLayoutEffect(() => {
    const element = probe.current;
    if (!element) return;
    const read = () =>
      setScheme(getComputedStyle(element).color === 'rgb(255, 255, 255)' ? 'dark' : 'light');
    read();
    const observer = new MutationObserver(read);
    for (let node = element.parentElement; node; node = node.parentElement)
      observer.observe(node, {
        attributes: true,
        attributeFilter: ['class', 'style', 'data-color-scheme', 'data-theme'],
      });
    const media = window.matchMedia('(prefers-color-scheme: dark)');
    media.addEventListener('change', read);
    return () => {
      observer.disconnect();
      media.removeEventListener('change', read);
    };
  }, [probe]);
  return scheme;
}

export function SpiritDragon({
  className,
  autoplay = true,
  speed = 1,
  glow = 0.7,
  colorScheme,
}: {
  className?: string;
  /** Swims on its own while on screen (false: still until the play button is pressed). */
  autoplay?: boolean;
  /** How fast it swims and everything sways, 1 being the painting's own pace. */
  speed?: number;
  /** How brightly the dragon glows, 0 to 1. */
  glow?: number;
  colorScheme?: 'light' | 'dark' | 'system';
}) {
  const id = useId().replace(/:/g, '');
  const probeRef = useRef<HTMLSpanElement>(null);
  const { ref, visible, reduced } = useAutoplay<HTMLDivElement>();
  // The reader's choice from the button, when they made one.
  const [chosen, setChosen] = useState<boolean | null>(null);
  const wanted = chosen ?? (autoplay && !reduced);
  const playing = visible && wanted;

  const scheme = useScheme(probeRef);
  const parts = useRef<Record<Part, SVGPathElement | null>>({
    body: null,
    scales: null,
    rims: null,
    lines: null,
    maneMass: null,
    maneLocks: null,
    maneHair: null,
    frills: null,
    fan: null,
    fanRibs: null,
    crest: null,
    whiskers: null,
  });
  const headRef = useRef<SVGGElement>(null);
  const haloRef = useRef<SVGPathElement>(null);
  const moteRefs = useRef<(SVGEllipseElement | null)[]>([]);
  const clock = useRef({ time: 0 });
  const motes = useRef<Mote[]>([]);
  const lastFrame = useRef<Frame | null>(null);

  const paint = (frame: Frame) => {
    lastFrame.current = frame;
    const p = parts.current;
    for (const key of Object.keys(p) as Part[]) p[key]?.setAttribute('d', frame[key]);
    haloRef.current?.setAttribute('d', frame.body);
    headRef.current?.setAttribute('transform', frame.head);
  };

  // The still frame, before the first paint, and again whenever the layers are drawn anew for a
  // new colour scheme.
  useLayoutEffect(() => {
    paint(lastFrame.current ?? drawDragon(clock.current.time));
  }, [scheme]);

  const rate = Math.max(0.1, speed);
  useEffect(() => {
    if (!playing) return;
    let frameId = 0;
    let then = performance.now();
    const tick = (now: number) => {
      const dt = Math.min(0.05, (now - then) / 1000);
      then = now;
      const c = clock.current;
      c.time += dt * rate;
      const frame = drawDragon(c.time);
      paint(frame);

      // Motes: each drifts up and away, fades, then starts again from somewhere on the body.
      const list = motes.current;
      for (let k = 0; k < SHED; k++) {
        let mote = list[k];
        if (!mote || mote.age >= mote.life) {
          const point = frame.spine[Math.floor(Math.random() * frame.spine.length)];
          const side = Math.random() < 0.5 ? -1 : 1;
          const off = 40 + Math.random() * 50;
          mote = list[k] = {
            x: point.x - point.ty * off * side,
            y: point.y + point.tx * off * side,
            vx: (Math.random() - 0.5) * 30 - point.tx * 10,
            vy: -12 - Math.random() * 22,
            age: mote ? 0 : -Math.random() * 3,
            life: 2.5 + Math.random() * 2.5,
          };
        }
        mote.age += dt * rate;
        mote.x += mote.vx * dt * rate;
        mote.y += mote.vy * dt * rate;
        const element = moteRefs.current[k];
        if (element) {
          const f = Math.max(0, mote.age / mote.life);
          element.setAttribute('cx', mote.x.toFixed(1));
          element.setAttribute('cy', mote.y.toFixed(1));
          element.setAttribute(
            'opacity',
            mote.age < 0 ? '0' : (Math.sin(Math.PI * f) * 0.95).toFixed(2),
          );
        }
      }
      frameId = requestAnimationFrame(tick);
    };
    frameId = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frameId);
  }, [playing, rate]);

  const strength = Math.max(0, Math.min(1, glow));
  const style = { '--speed': rate, '--glow': strength } as CSSProperties;

  return (
    <div
      ref={ref}
      className={`${styles.root} ${className ?? ''}`}
      data-color-scheme={colorScheme}
      data-playing={playing}
      style={style}
    >
      <div
        className={styles.stage}
        data-part="stage"
        role="img"
        aria-label="A painted teal forest at dusk: a translucent white dragon swims between the trees, above a child in a red hoodie holding up a lantern, and a small fox."
      >
        <svg
          key={`back-${scheme}`}
          className={styles.layer}
          viewBox="0 0 2000 1500"
          preserveAspectRatio="xMidYMid slice"
          aria-hidden="true"
          data-part="forest"
        >
          <BackScene id={id} />
        </svg>
        <div className={styles.mist} aria-hidden="true" data-part="mist">
          <span />
          <span />
          <span />
        </div>
        <svg
          key={`dragon-${scheme}`}
          className={`${styles.layer} ${styles.dragon}`}
          viewBox="0 0 2000 1500"
          preserveAspectRatio="xMidYMid slice"
          aria-hidden="true"
          data-part="dragon"
        >
          <path
            ref={(el) => void (parts.current.fan = el)}
            className={styles.fan}
            data-part="tail-fan"
          />
          <path ref={(el) => void (parts.current.fanRibs = el)} className={styles.fanRibs} />
          <path ref={haloRef} className={styles.halo} />
          <path
            ref={(el) => void (parts.current.body = el)}
            className={styles.body}
            data-part="body"
          />
          <path ref={(el) => void (parts.current.scales = el)} className={styles.scales} />
          <path ref={(el) => void (parts.current.rims = el)} className={styles.rims} />
          <path ref={(el) => void (parts.current.lines = el)} className={styles.lines} />
          <path ref={(el) => void (parts.current.frills = el)} className={styles.frills} />
          <path
            ref={(el) => void (parts.current.maneMass = el)}
            className={styles.maneMass}
            data-part="mane"
          />
          <path ref={(el) => void (parts.current.maneLocks = el)} className={styles.maneLocks} />
          <path ref={(el) => void (parts.current.maneHair = el)} className={styles.maneHair} />
          <g ref={headRef} data-part="head">
            <path ref={(el) => void (parts.current.crest = el)} className={styles.crest} />
            <path className={styles.head} d={HEAD_SHAPE} />
            <path className={styles.face} d={HEAD_DETAIL} />
            <path className={styles.eyeGlow} d={HEAD_EYE} />
            <path className={styles.eye} d={HEAD_EYE} />
            <path ref={(el) => void (parts.current.whiskers = el)} className={styles.whiskers} />
          </g>
          {Array.from({ length: SHED }, (_, k) => (
            <ellipse
              key={k}
              ref={(el) => void (moteRefs.current[k] = el)}
              className={styles.shed}
              rx={k % 3 ? 4 : 7}
              ry={k % 3 ? 3 : 5}
              opacity="0"
            />
          ))}
        </svg>
        <svg
          key={`front-${scheme}`}
          className={styles.layer}
          viewBox="0 0 2000 1500"
          preserveAspectRatio="xMidYMid slice"
          aria-hidden="true"
        >
          <FrontScene id={id} />
          {MOTES.map((mote, k) => (
            <ellipse
              key={k}
              className={styles.mote}
              cx={mote.x}
              cy={mote.y}
              rx={mote.r * 1.5}
              ry={mote.r}
              style={
                {
                  '--delay': `${-((k * 1.7) % 7)}s`,
                  '--dx': `${((k * 37) % 50) - 25}px`,
                  '--dy': `${-30 - ((k * 23) % 40)}px`,
                  '--length': `${7 + ((k * 13) % 6)}s`,
                } as CSSProperties
              }
            />
          ))}
        </svg>
        <svg className={styles.grain} aria-hidden="true">
          <filter id={`${id}-grain`}>
            <feTurbulence
              type="fractalNoise"
              baseFrequency="0.85"
              numOctaves="3"
              seed="7"
              stitchTiles="stitch"
            />
            <feColorMatrix type="saturate" values="0" />
          </filter>
          <rect width="100%" height="100%" filter={`url(#${id}-grain)`} />
        </svg>
      </div>
      <span ref={probeRef} className={styles.probe} aria-hidden="true" />
      <button
        type="button"
        className={styles.toggle}
        data-part="toggle"
        aria-label={wanted ? 'Pause the animation' : 'Play the animation'}
        onClick={() => setChosen(!wanted)}
      >
        <svg viewBox="0 0 16 16" aria-hidden="true">
          {wanted ? (
            <path d="M4.5 3h2.4v10H4.5zM9.1 3h2.4v10H9.1z" />
          ) : (
            <path d="M5 2.8v10.4L13.2 8z" />
          )}
        </svg>
      </button>
    </div>
  );
}
```

### spirit-dragon/dragon.ts

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

import { smooth, Track, type P } from './geometry';

/*
 * The spirit dragon, drawn fresh for every frame. It holds the painting's pose and swims in place.
 *
 * - The pose is a smooth line through hand-placed points, from the tip of the tail to the back of
 *   the head (plus a little past each end). The spine is SEGMENTS + 1 points spread along it
 *   behind the head; each frame the whole spine slides a few units back and forth along the line,
 *   a wave runs down it from neck to tail, and the dragon drifts a little as a whole. So the body
 *   undulates through the same curves without ever leaving them.
 * - Everything hangs off the spine. The body is a ribbon around it, its width set at each control
 *   point; the scales are staggered rows of small arcs fixed to spine points, so they ride the
 *   wave; the mane is long, fine strands that start on the back of the neck and lie back along it,
 *   each strand following the spine further down, flaring away from it and waving on its own
 *   clock; the tail ends in a fan: a pale membrane spread between bright ribs.
 * - The head is one drawing in its own units (the neck joint at 0,0, the neck running to +x),
 *   moved and turned to the first spine point; its crest of soft spikes and its whiskers wave.
 */

/** The pose, from just past the tip of the tail to just past the head; [x, y, body width]. The
 *  first and last two points only extend the line, so the spine can slide along it. */
const POSE: [number, number, number][] = [
  [2110, 752, 20],
  [2040, 782, 20],
  // The tail, rising to the right under its fan.
  [1966, 812, 22],
  [1890, 842, 34],
  [1800, 874, 48],
  [1710, 910, 62],
  [1620, 950, 84],
  // The S on the right.
  [1530, 980, 110],
  [1462, 980, 122],
  [1416, 946, 124],
  [1404, 888, 124],
  [1428, 820, 124],
  [1478, 750, 124],
  [1538, 680, 124],
  [1576, 596, 124],
  [1592, 542, 124],
  [1562, 508, 124],
  [1502, 506, 124],
  [1434, 536, 126],
  // The long band across the middle, behind the teal bough.
  [1352, 580, 128],
  [1270, 634, 130],
  [1180, 670, 130],
  [1090, 704, 130],
  [1000, 740, 130],
  [905, 790, 128],
  [810, 846, 124],
  [714, 900, 120],
  // The curl around the teal trunk.
  [622, 934, 116],
  [540, 934, 112],
  [484, 902, 110],
  [470, 852, 110],
  [502, 816, 112],
  [572, 806, 116],
  [644, 800, 122],
  [700, 770, 130],
  // The neck, rising to the head.
  [712, 704, 140],
  [704, 628, 148],
  [686, 552, 156],
  [652, 484, 162],
  [604, 432, 166],
  [538, 400, 168],
  [460, 388, 164],
  [380, 386, 160],
  [300, 388, 160],
];
/** The index of the neck joint, where the head is drawn, in the pose. */
const HEAD = POSE.length - 3;

export const track = new Track(POSE.map(([x, y]) => [x, y] as P));
const TAIL_AT = track.marks[2];
const HEAD_AT = track.marks[HEAD];
const BODY = HEAD_AT - TAIL_AT;

export const SEGMENTS = 280;
const SPACING = BODY / SEGMENTS;
/** Scales across the body. */
const ACROSS = 6;
/** How far the mane runs down the neck, and where the tail's fan starts (shares of the body). */
const MANE = 0.15;
const FAN = 1 - 340 / BODY;

/** The body's width at each spine point in the still pose, from the pose's widths. */
const BASE_WIDTH = (() => {
  const widths = new Float64Array(SEGMENTS + 1);
  for (let i = 0; i <= SEGMENTS; i++) {
    const s = HEAD_AT - i * SPACING;
    let k = 0;
    while (k < POSE.length - 2 && track.marks[k + 1] < s) k++;
    const f = Math.min(
      1,
      Math.max(0, (s - track.marks[k]) / (track.marks[k + 1] - track.marks[k])),
    );
    const smoothF = f * f * (3 - 2 * f);
    widths[i] = POSE[k][2] + (POSE[k + 1][2] - POSE[k][2]) * smoothF;
  }
  // The very tip narrows to a point.
  for (let i = SEGMENTS - 8; i <= SEGMENTS; i++) widths[i] *= (SEGMENTS - i + 1) / 9;
  return widths;
})();

const hash = (i: number) => {
  const x = Math.sin(i * 127.1 + 311.7) * 43758.5453;
  return x - Math.floor(x);
};
const n0 = (value: number) => Math.round(value);

// Work buffers, reused every frame.
const X = new Float64Array(SEGMENTS + 1);
const Y = new Float64Array(SEGMENTS + 1);
const TX = new Float64Array(SEGMENTS + 1);
const TY = new Float64Array(SEGMENTS + 1);
const WD = new Float64Array(SEGMENTS + 1);
const scratch = { x: 0, y: 0 };

/** A smooth open path through points given by `at(i)` for i from `from` to `to` (either way). */
function polyline(from: number, to: number, at: (i: number) => [number, number]) {
  const step = to >= from ? 1 : -1;
  let [px, py] = at(from);
  let d = `M${n0(px)} ${n0(py)}`;
  for (let i = from + step; step > 0 ? i <= to : i >= to; i += step) {
    const [x, y] = at(i);
    if (i === to) d += `L${n0(x)} ${n0(y)}`;
    else d += `Q${n0(px)} ${n0(py)} ${n0((px + x) / 2)} ${n0((py + y) / 2)}`;
    px = x;
    py = y;
  }
  return d;
}

/** A soft spike: from `a` (`w` wide) to a round tip at `b`, bowed by `bow` to its left. */
function spike(ax: number, ay: number, bx: number, by: number, w: number, bow = 0) {
  const dx = bx - ax;
  const dy = by - ay;
  const length = Math.hypot(dx, dy) || 1;
  const ux = dx / length;
  const uy = dy / length;
  const nx = -uy;
  const ny = ux;
  const mx = ax + dx * 0.5 + nx * bow;
  const my = ay + dy * 0.5 + ny * bow;
  const h0 = w / 2;
  const h1 = w * 0.4;
  const ht = w * 0.22;
  return (
    `M${n0(ax + nx * h0)} ${n0(ay + ny * h0)}` +
    `Q${n0(mx + nx * h1)} ${n0(my + ny * h1)} ${n0(bx + nx * ht)} ${n0(by + ny * ht)}` +
    `Q${n0(bx + ux * ht * 2)} ${n0(by + uy * ht * 2)} ${n0(bx - nx * ht)} ${n0(by - ny * ht)}` +
    `Q${n0(mx - nx * h1)} ${n0(my - ny * h1)} ${n0(ax - nx * h0)} ${n0(ay - ny * h0)}Z`
  );
}

/** The crest's spikes on the head, base to tip, in the head's own units: x, y, x, y, width. */
const CREST: [number, number, number, number, number][] = [
  [-196, -58, -168, -132, 22],
  [-164, -70, -116, -176, 26],
  [-128, -80, -60, -214, 28],
  [-92, -88, -6, -250, 30],
  [-52, -94, 52, -232, 28],
  [-14, -96, 108, -262, 30],
  [24, -92, 160, -214, 28],
  [60, -84, 196, -178, 26],
  [86, -70, 214, -126, 22],
];
/** The ruff: spikes hanging back and down from behind the jaw, same units. */
const RUFF: [number, number, number, number, number][] = [
  [-150, 26, -112, 92, 16],
  [-112, 36, -46, 112, 20],
  [-74, 44, 6, 120, 22],
  [-36, 52, 54, 110, 20],
];

/** The tail fan's blades: where each starts along the fan (0 to 1), its angle off the tail
 *  (radians, towards the back), its length and width. */
const FAN_BLADES: [number, number, number, number][] = [
  [0, 0.7, 220, 34],
  [0.18, 0.62, 290, 38],
  [0.36, 0.54, 320, 38],
  [0.54, 0.46, 300, 34],
  [0.72, 0.38, 250, 30],
  [0.88, 0.28, 180, 24],
];

/** The head's outline, in its own units: the neck joint at 0,0, the snout to the left. */
export const HEAD_SHAPE = smooth([
  [-332, 2],
  [-326, -12],
  [-306, -24],
  [-272, -34],
  [-240, -46],
  [-212, -62],
  [-186, -74],
  [-156, -76],
  [-124, -82],
  [-84, -92],
  [-36, -98],
  [14, -96],
  [64, -86],
  [94, -40],
  [94, 40],
  [64, 76],
  [0, 78],
  [-62, 66],
  [-124, 52],
  [-186, 40],
  [-244, 30],
  [-292, 22],
  [-322, 14],
]);
/** The face: the brow, the mouth, the nostril. */
export const HEAD_DETAIL =
  'M-236 -46Q-206 -66 -166 -66M-322 4Q-250 22 -150 32M-300 -12q5 -4 10 -2M-120 40q30 10 70 14';
/** The eye: a glowing line under the brow. */
export const HEAD_EYE = 'M-204 -38Q-188 -46 -166 -42';

export type Frame = {
  body: string;
  scales: string;
  rims: string;
  lines: string;
  maneMass: string;
  maneLocks: string;
  maneHair: string;
  frills: string;
  fan: string;
  fanRibs: string;
  head: string;
  crest: string;
  whiskers: string;
  /** A few spine points, for the glowing motes that drift off the body. */
  spine: { x: number; y: number; tx: number; ty: number }[];
};

/** One frame of the dragon, `time` seconds into its swim. */
export function drawDragon(time: number): Frame {
  // Sliding back and forth along the pose, and drifting a little as a whole.
  const slide = 26 * Math.sin(time * 0.42) + 9 * Math.sin(time * 0.77 + 1.3);
  const driftX = 9 * Math.sin(time * 0.29 + 0.4);
  const driftY = 11 * Math.sin(time * 0.37 + 1.1);
  const headAt = HEAD_AT + slide;
  for (let i = 0; i <= SEGMENTS; i++) {
    track.point(headAt - i * SPACING, scratch);
    X[i] = scratch.x + driftX;
    Y[i] = scratch.y + driftY;
  }
  // Tangents (pointing to the head), then the wave across them, then the tangents again.
  const tangents = () => {
    for (let i = 0; i <= SEGMENTS; i++) {
      const a = Math.max(0, i - 1);
      const b = Math.min(SEGMENTS, i + 1);
      const dx = X[a] - X[b];
      const dy = Y[a] - Y[b];
      const length = Math.hypot(dx, dy) || 1;
      TX[i] = dx / length;
      TY[i] = dy / length;
    }
  };
  tangents();
  for (let i = 0; i <= SEGMENTS; i++) {
    const u = i / SEGMENTS;
    // The wave grows from the neck to the tail and runs down the body.
    const reach = 5 + 17 * Math.min(1, u * 1.6);
    const sway = reach * Math.sin(u * 15 - time * 1.9) + 4 * Math.sin(u * 31 - time * 2.7);
    X[i] += -TY[i] * sway;
    Y[i] += TX[i] * sway;
    WD[i] = BASE_WIDTH[i] * (0.94 + 0.06 * Math.sin(u * 9 - time * 1.3));
  }
  tangents();

  // The normal (-ty, tx) of the spine is the dragon's back.
  const edge = (i: number, k: number): [number, number] => [
    X[i] - TY[i] * WD[i] * k,
    Y[i] + TX[i] * WD[i] * k,
  ];

  // The ribbon: its back down to the tail, its belly back up.
  const body =
    polyline(0, SEGMENTS, (i) => edge(i, 0.5)) +
    polyline(SEGMENTS, 0, (i) => edge(i, -0.5)).replace('M', 'L') +
    'Z';
  // The bright rims just inside both edges.
  const rims =
    polyline(2, SEGMENTS - 4, (i) => edge(i, 0.44)) +
    polyline(2, SEGMENTS - 4, (i) => edge(i, -0.44));

  // Scales: staggered rows of arcs that open towards the tail.
  let scales = '';
  for (let i = 3; i < SEGMENTS - 10; i++) {
    const shift = i % 2 ? 0.5 : 0;
    const across = WD[i] * 0.86;
    const r = across / ACROSS / 2;
    for (let j = 0; j < ACROSS; j++) {
      const q = (j + 0.25 + shift) / ACROSS - 0.5;
      if (Math.abs(q) > 0.48) continue;
      const cx = X[i] - TY[i] * across * q;
      const cy = Y[i] + TX[i] * across * q;
      const ax = -TY[i] * r;
      const ay = TX[i] * r;
      scales += `M${n0(cx + ax)} ${n0(cy + ay)}q${n0(-ax - TX[i] * r * 2.1)} ${n0(-ay - TY[i] * r * 2.1)} ${n0(-2 * ax)} ${n0(-2 * ay)}`;
    }
  }

  // Two fine lines inside the body, near its back and its belly.
  const last = Math.floor(SEGMENTS * 0.9);
  const lines =
    polyline(6, last, (i) => edge(i, 0.3 + 0.03 * Math.sin(i * 0.5 - time * 2.4))) +
    polyline(10, last, (i) => edge(i, -(0.34 + 0.03 * Math.sin(i * 0.4 + time * 2))));

  // The mane: a pale mass lying back over the neck, on the outside of its curve, its edge
  // rippling; strands drawn over it, and locks that stream past its end.
  const maneEnd = Math.floor(SEGMENTS * MANE);
  // How far the mane stands off the body's back at spine point i.
  const maneOff = (i: number) => {
    const t = i / maneEnd;
    const swell = Math.sin(Math.PI * Math.min(1, t * 1.05)) ** 0.7;
    return (
      WD[i] * 0.5 +
      (14 + 78 * swell) * (t < 0.12 ? t / 0.12 : 1) +
      9 * Math.sin(t * 22 - time * 2.6) * swell
    );
  };
  const atOff = (i: number, off: number): [number, number] => [
    X[i] - TY[i] * off,
    Y[i] + TX[i] * off,
  ];
  const maneMass =
    polyline(0, maneEnd, (i) => atOff(i, maneOff(i))) +
    polyline(maneEnd, 0, (i) => atOff(i, WD[i] * 0.18)).replace('M', 'L') +
    'Z';
  let maneLocks = '';
  let maneHair = '';
  for (let k = 0; k < 56; k++) {
    const root = Math.floor(hash(k + 2) * maneEnd * 0.85);
    const span = Math.floor(10 + 14 * hash(k + 4));
    const end = Math.min(SEGMENTS - 1, root + span);
    const start = 0.2 + 0.25 * hash(k + 11);
    const reach = 0.55 + 0.6 * hash(k + 17);
    const phase = k * 0.9;
    const point = (i: number): [number, number] => {
      const t = (i - root) / (end - root || 1);
      const top = maneOff(Math.min(i, maneEnd)) - (i > maneEnd ? (i - maneEnd) * 3 : 0);
      const off =
        WD[i] * start * (1 - t) + top * reach * t + Math.sin(time * 2.2 - t * 5 + phase) * 10 * t;
      return atOff(i, off);
    };
    const strand = polyline(root, end, point);
    if (end > maneEnd - 4 && k % 2 === 0) {
      // A lock streaming past the mane's end: the strand and a line beside it, tapering.
      const width = 10 + 12 * hash(k + 23);
      const other = polyline(end, root, (i) => {
        const [x, y] = point(i);
        const t = (i - root) / (end - root || 1);
        return [x + TY[i] * width * (1 - t), y - TX[i] * width * (1 - t)];
      });
      maneLocks += strand + other.replace('M', 'L') + 'Z';
    } else maneHair += strand;
  }

  // Frills along the belly: short wisps that trail back.
  let frills = '';
  for (let i = maneEnd; i < SEGMENTS * 0.8; i += 4) {
    const [bx, by] = edge(i, -0.48);
    const nx = TY[i];
    const ny = -TX[i];
    const length = 14 + 14 * hash(i);
    const wave = Math.sin(time * 3 - i * 0.3) * 6;
    frills += `M${n0(bx)} ${n0(by)}q${n0(nx * length * 0.6 - TX[i] * 6 + wave)} ${n0(ny * length * 0.6 - TY[i] * 6)} ${n0(nx * length - TX[i] * length * 0.9)} ${n0(ny * length - TY[i] * length * 0.9)}`;
  }

  // The tail's fan: six blades fanning out from the back of the tail like a wing's feathers,
  // each turned further off the tail the nearer the body it starts, a pale membrane spread
  // between their tips, and one long blade under the tail, past its tip.
  const fanStart = SEGMENTS * FAN;
  const base = (v: number) => Math.min(SEGMENTS, Math.round(fanStart + v * (SEGMENTS - fanStart)));
  /** A point `length` along the direction `angle` off the tail (towards its back) at spine i. */
  const ray = (i: number, from: [number, number], angle: number, length: number) => {
    const c = Math.cos(angle);
    const sn = Math.sin(angle);
    const dx = -TX[i] * c - TY[i] * sn;
    const dy = -TY[i] * c + TX[i] * sn;
    return [from[0] + dx * length, from[1] + dy * length] as [number, number];
  };
  let fanRibs = '';
  const fanBases: [number, number][] = [];
  const fanTips: [number, number][] = [];
  FAN_BLADES.forEach(([v, angle, length, width], k) => {
    const i = base(v);
    const from = edge(i, 0.3);
    const wave = Math.sin(time * 2.1 - k * 0.7) * 0.06;
    const tip = ray(i, from, angle + wave, length * (1 + 0.03 * Math.sin(time * 1.7 - k)));
    fanBases.push(from);
    fanTips.push(tip);
    fanRibs += spike(from[0], from[1], tip[0], tip[1], width, 14);
  });
  {
    const i = base(0.35);
    const from = edge(i, -0.1);
    const tip = ray(SEGMENTS - 2, from, -0.06 + Math.sin(time * 2.1 - 4) * 0.05, 250);
    fanRibs += spike(from[0], from[1], tip[0], tip[1], 18, -8);
  }
  const fan =
    polyline(0, fanBases.length - 1, (r) => fanBases[r]) +
    polyline(fanTips.length - 1, 0, (r) => fanTips[r]).replace('M', 'L') +
    'Z';

  // The head, turned to the neck.
  const angle = (Math.atan2(-TY[0], -TX[0]) * 180) / Math.PI;
  const head = `translate(${n0(X[0])} ${n0(Y[0])}) rotate(${angle.toFixed(1)})`;

  let crest = '';
  CREST.forEach(([bx, by, tx, ty, w], k) => {
    const sway = Math.sin(time * 1.6 - k * 0.55) * 10;
    crest += spike(bx, by, tx + sway * 0.7, ty - sway * 0.25, w, 8 + k);
  });
  RUFF.forEach(([bx, by, tx, ty, w], k) => {
    const sway = Math.sin(time * 1.9 - k * 0.7) * 8;
    crest += spike(bx, by, tx + sway, ty - sway * 0.3, w, -12);
  });
  const a = Math.sin(time * 1.4);
  const b = Math.sin(time * 1.4 + 1.6);
  const c = Math.sin(time * 1.1 + 0.6);
  const whiskers =
    // The long whisker from the brow, up over the crest.
    `M-232 -50C${n0(-200 + a * 6)} -120 ${n0(-60 + b * 14)} ${n0(-250 + a * 10)} ${n0(150 + c * 22)} ${n0(-292 + b * 16)}` +
    // One from the cheek, back over the neck.
    `M-160 -28C-40 ${n0(-70 + a * 10)} 160 ${n0(-74 + b * 12)} ${n0(330 + c * 18)} ${n0(-46 + a * 16)}` +
    // The beard under the jaw.
    `M-96 44C${n0(-80 + a * 4)} 80 ${n0(-56 + b * 8)} 108 ${n0(-34 + c * 12)} 128` +
    `M-70 50C${n0(-50 + b * 4)} 82 ${n0(-26 + c * 8)} 104 ${n0(-4 + a * 12)} 118` +
    `M-44 56C${n0(-26 + c * 4)} 84 ${n0(-4 + a * 8)} 100 ${n0(20 + b * 10)} 108` +
    `M-118 40C${n0(-110 + c * 4)} 70 ${n0(-96 + a * 6)} 92 ${n0(-82 + b * 8)} 108`;

  const spine = [];
  for (let i = 4; i < SEGMENTS; i += 12) spine.push({ x: X[i], y: Y[i], tx: TX[i], ty: TY[i] });

  return {
    body,
    scales,
    rims,
    lines,
    maneMass,
    maneLocks,
    maneHair,
    frills,
    fan,
    fanRibs,
    head,
    crest,
    whiskers,
    spine,
  };
}
```

### spirit-dragon/scene.tsx

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

import {
  brushStrokes,
  knotHoles,
  limb,
  limbBand,
  seeded,
  smooth,
  twig,
  type P,
  type W,
} from './geometry';
import styles from './spirit-dragon.module.css';

/*
 * The painting the dragon swims through, in the scene's units (2000 × 1500), as still SVG drawn
 * once: the sky, the far trees and hills, the trees the dragon weaves between, the near hills,
 * and in front of it all the big dark trunk, the reeds, the child with the lantern and the fox.
 *
 * Every trunk and branch is a limb (a middle line with a width at each point), so its brush
 * streaks can follow it. Fills are classes, so the colours are the stylesheet's light-dark()
 * tokens. The few parts that move (hair, hood, lantern, fox's tail, reeds, motes) are their own
 * elements, animated by CSS transforms in the stylesheet.
 */

type Limb = {
  line: W[];
  tone: string;
  /** A darker band across part of it: from, to (−0.5 to 0.5 across), and its class. */
  shade?: [number, number, string];
  /** How many brush streaks, of each kind. */
  light?: number;
  dark?: number;
  rust?: number;
  /** Dark knots and hollows in the bark. */
  knots?: number;
};

const s = styles;

/** The far trees, pale with distance. */
const FAR: Limb[] = [
  {
    line: [
      [122, -20, 92],
      [118, 300, 96],
      [112, 700, 102],
      [108, 1080, 104],
    ],
    tone: s.far1,
    light: 14,
    dark: 6,
  },
  {
    line: [
      [10, 330, 70],
      [24, 600, 80],
      [16, 1000, 90],
    ],
    tone: s.far3,
    dark: 5,
  },
  {
    line: [
      [1897, -20, 92],
      [1890, 300, 94],
      [1882, 650, 98],
      [1884, 1060, 104],
    ],
    tone: s.far2,
    shade: [0.18, 0.5, s.far2Shade],
    light: 16,
    dark: 8,
  },
  {
    line: [
      [1972, -20, 40],
      [1970, 200, 46],
      [1978, 400, 56],
      [1980, 700, 62],
      [1984, 1020, 66],
    ],
    tone: s.far3,
    light: 8,
  },
  {
    line: [
      [1965, 170, 16],
      [1942, 140, 12],
      [1930, 120, 8],
    ],
    tone: s.far3,
  },
];

/** The trees the dragon weaves between: it passes in front of some, behind others. Each list
 * draws a tree's boughs before its trunk, so they grow out of it. The big trunk on the left
 * stands behind the left hill; the rest stand in front of it. */
const TREES_LEFT: Limb[] = [
  {
    line: [
      [36, 250, 60],
      [-20, 180, 54],
      [-70, 140, 50],
    ],
    tone: s.trunkBlue,
  },
  {
    line: [
      [232, 340, 62],
      [240, 200, 55],
      [262, 80, 46],
      [292, -20, 44],
    ],
    knots: 4,
    tone: s.trunkBlue,
    shade: [0.1, 0.5, s.trunkBlueShade],
    light: 8,
    rust: 4,
  },
  {
    line: [
      [248, 70, 16],
      [214, 32, 11],
      [192, 18, 8],
    ],
    tone: s.trunkBlue,
  },
  {
    line: [
      [270, 40, 14],
      [302, 12, 10],
      [330, -10, 8],
    ],
    tone: s.trunkBlue,
  },
  {
    line: [
      [200, 320, 20],
      [172, 205, 15],
      [152, 145, 11],
      [140, 112, 8],
    ],
    tone: s.trunkBlue,
  },
  {
    line: [
      [176, 210, 11],
      [196, 150, 8],
      [216, 118, 6],
    ],
    tone: s.trunkBlue,
  },
  {
    line: [
      [10, -20, 150],
      [70, 150, 165],
      [115, 300, 180],
      [175, 500, 215],
      [258, 620, 235],
      [325, 720, 245],
      [378, 850, 185],
      [398, 1000, 145],
      [404, 1090, 150],
      [408, 1160, 150],
    ],
    knots: 14,
    tone: s.trunkBlue,
    shade: [0.24, 0.5, s.trunkBlueShade],
    light: 46,
    dark: 26,
    rust: 10,
  },
  {
    line: [
      [312, 800, 26],
      [230, 780, 24],
      [140, 752, 22],
      [60, 720, 22],
      [-20, 690, 22],
    ],
    tone: s.trunkNavy,
    light: 6,
  },
  {
    line: [
      [452, 922, 46],
      [380, 852, 40],
      [310, 818, 36],
      [220, 846, 34],
      [120, 870, 34],
      [20, 866, 32],
      [-20, 862, 32],
    ],
    knots: 4,
    tone: s.trunkNavy,
    shade: [0.1, 0.5, s.trunkNavyShade],
    light: 12,
    rust: 4,
  },
];

const TREES_RIGHT: Limb[] = [
  // A teal bough reaching down from the top, the teal trunk in the middle left and its boughs,
  // the great navy trunk with its forks and limbs, and the blue tree on the right.
  {
    line: [
      [300, -20, 30],
      [330, 70, 28],
      [380, 130, 26],
      [440, 180, 24],
      [520, 215, 26],
      [585, 232, 30],
    ],
    tone: s.branchTeal,
    light: 8,
    rust: 6,
  },
  {
    line: [
      [380, 130, 16],
      [405, 70, 12],
      [440, 15, 10],
      [470, -10, 8],
    ],
    tone: s.branchTeal,
  },
  {
    line: [
      [440, 15, 8],
      [510, 5, 6],
      [560, -5, 5],
    ],
    tone: s.branchTeal,
  },
  {
    line: [
      [600, 236, 46],
      [700, 195, 42],
      [820, 155, 38],
      [950, 118, 34],
      [1080, 88, 30],
      [1200, 62, 26],
      [1330, 40, 22],
      [1430, 10, 18],
      [1500, -20, 16],
    ],
    tone: s.branchTeal,
    shade: [0.15, 0.5, s.branchTealShade],
    light: 20,
    rust: 14,
  },
  {
    line: [
      [615, 282, 40],
      [760, 238, 34],
      [900, 198, 30],
      [1030, 172, 26],
      [1150, 163, 20],
      [1222, 185, 14],
    ],
    tone: s.branchTeal,
    shade: [0.15, 0.5, s.branchTealShade],
    light: 12,
    rust: 8,
  },
  {
    line: [
      [625, 95, 30],
      [690, 55, 26],
      [760, 15, 22],
      [800, -20, 20],
    ],
    tone: s.branchTeal,
    light: 4,
    rust: 4,
  },
  {
    line: [
      [640, 150, 30],
      [800, 100, 26],
      [960, 45, 22],
      [1060, 5, 18],
      [1100, -20, 16],
    ],
    tone: s.branchTeal,
    light: 6,
    rust: 6,
  },
  {
    line: [
      [610, -20, 70],
      [610, 100, 78],
      [605, 250, 95],
      [600, 400, 150],
      [595, 520, 190],
      [590, 640, 215],
      [600, 760, 250],
      [615, 880, 280],
      [630, 1000, 300],
      [650, 1120, 320],
      [680, 1250, 360],
      [690, 1360, 420],
    ],
    knots: 12,
    tone: s.trunkTeal,
    shade: [-0.5, -0.12, s.trunkNavy],
    light: 50,
    dark: 20,
    rust: 16,
  },
  {
    line: [
      [1000, 330, 75],
      [920, 240, 65],
      [840, 150, 55],
      [770, 60, 45],
      [735, -20, 40],
    ],
    tone: s.trunkNavy,
    light: 10,
    dark: 6,
  },
  {
    line: [
      [985, 500, 40],
      [920, 470, 30],
      [850, 440, 26],
      [790, 390, 24],
      [750, 322, 22],
      [700, 276, 20],
      [668, 266, 18],
    ],
    tone: s.branchTeal,
    light: 8,
    rust: 4,
  },
  {
    line: [
      [1150, 200, 95],
      [1230, 140, 85],
      [1310, 75, 75],
      [1400, 10, 70],
      [1450, -25, 65],
    ],
    knots: 4,
    tone: s.trunkNavy,
    shade: [0.15, 0.5, s.trunkNavyShade],
    light: 12,
    rust: 6,
  },
  {
    line: [
      [1080, 250, 150],
      [1200, 282, 142],
      [1300, 328, 132],
      [1400, 402, 122],
      [1474, 494, 112],
      [1530, 590, 104],
      [1556, 650, 96],
    ],
    knots: 6,
    tone: s.limbTeal,
    shade: [0.12, 0.5, s.limbTealShade],
    light: 28,
    dark: 12,
    rust: 10,
  },
  {
    line: [
      [1096, 470, 104],
      [1230, 484, 100],
      [1340, 502, 96],
      [1436, 534, 92],
      [1506, 584, 88],
      [1540, 630, 84],
    ],
    knots: 3,
    tone: s.limbTeal,
    shade: [0.1, 0.5, s.limbTealShade],
    light: 14,
    rust: 6,
  },
  {
    line: [
      [1070, 130, 46],
      [990, 80, 36],
      [920, 30, 28],
      [880, -20, 24],
    ],
    tone: s.trunkNavy,
    light: 4,
  },
  {
    line: [
      [1095, -20, 110],
      [1090, 100, 140],
      [1060, 220, 190],
      [1048, 330, 200],
      [1055, 450, 215],
      [1075, 560, 225],
      [1098, 660, 255],
      [1138, 760, 275],
      [1200, 880, 280],
      [1245, 1000, 310],
      [1270, 1120, 350],
      [1285, 1260, 370],
    ],
    knots: 16,
    tone: s.trunkNavy,
    shade: [0.2, 0.5, s.trunkNavyShade],
    light: 56,
    dark: 24,
    rust: 8,
  },
  {
    line: [
      [1610, 240, 48],
      [1520, 205, 40],
      [1430, 178, 32],
      [1350, 158, 24],
      [1300, 145, 18],
    ],
    tone: s.trunkR,
    light: 8,
  },
  {
    line: [
      [1430, 178, 14],
      [1398, 125, 10],
      [1382, 70, 8],
    ],
    tone: s.trunkR,
  },
  {
    line: [
      [1520, 205, 14],
      [1528, 140, 10],
      [1540, 70, 8],
    ],
    tone: s.trunkR,
  },
  {
    line: [
      [1615, 360, 46],
      [1520, 335, 36],
      [1430, 305, 28],
      [1350, 282, 20],
      [1318, 286, 14],
    ],
    tone: s.trunkR,
    light: 6,
  },
  {
    line: [
      [1430, 305, 12],
      [1398, 262, 10],
      [1362, 245, 8],
    ],
    tone: s.trunkR,
  },
  {
    line: [
      [1680, 210, 110],
      [1620, 100, 100],
      [1590, -20, 100],
    ],
    knots: 4,
    tone: s.trunkRLight,
    light: 8,
  },
  {
    line: [
      [1720, 190, 100],
      [1745, 90, 90],
      [1760, -20, 85],
    ],
    tone: s.trunkR,
    light: 6,
  },
  {
    line: [
      [1690, 170, 200],
      [1688, 300, 195],
      [1695, 450, 190],
      [1700, 600, 200],
      [1705, 750, 205],
      [1715, 900, 215],
      [1720, 1070, 230],
      [1724, 1180, 236],
    ],
    knots: 12,
    tone: s.trunkR,
    shade: [-0.5, -0.1, s.trunkRLight],
    light: 40,
    dark: 18,
  },
  {
    line: [
      [1606, 1070, 112],
      [1596, 960, 106],
      [1578, 850, 104],
      [1558, 740, 104],
      [1544, 640, 100],
      [1530, 590, 96],
    ],
    knots: 3,
    tone: s.limbTeal,
    shade: [0.15, 0.5, s.limbTealShade],
    light: 14,
    dark: 6,
    rust: 4,
  },
];

function Limbs({ limbs, seed }: { limbs: Limb[]; seed: number }) {
  return limbs.map((item, k) => {
    const knots = item.knots
      ? knotHoles(item.line, Math.ceil(item.knots / 3), seed + k * 17)
      : null;
    return (
      <g key={k}>
        <path className={item.tone} d={limb(item.line)} />
        {item.shade && (
          <path className={item.shade[2]} d={limbBand(item.line, item.shade[0], item.shade[1])} />
        )}
        {item.light ? (
          <path
            className={s.streakLight}
            d={brushStrokes(item.line, item.light * 2, seed + k * 7, 0.44)}
          />
        ) : null}
        {item.dark ? (
          <path
            className={s.streakDark}
            d={brushStrokes(item.line, item.dark * 2, seed + k * 11, 0.44)}
          />
        ) : null}
        {item.rust ? (
          <>
            <path
              className={s.rustWide}
              d={brushStrokes(item.line, item.rust, seed + k * 19, 0.38, 1.4)}
            />
            <path
              className={s.streakRust}
              d={brushStrokes(item.line, Math.ceil(item.rust * 1.5), seed + k * 13, 0.44)}
            />
          </>
        ) : null}
        {knots ? (
          <>
            <path className={s.knotLip} d={knots.lip} />
            <path className={s.knot} d={knots.hole} />
          </>
        ) : null}
      </g>
    );
  });
}

/** Bare twigs crossing the sky: where each starts, its angle, length, width and forks, and its
 *  colour. */
const TWIG_ROOTS: [number, number, number, number, number, number, string][] = [
  [180, 250, -2.25, 210, 22, 2, s.trunkBlue],
  [92, 150, -2.45, 160, 16, 2, s.trunkBlue],
  [252, 120, -1.75, 170, 16, 2, s.trunkBlue],
  [330, 50, -1.2, 130, 12, 2, s.trunkBlue],
  [560, 90, -2.05, 160, 16, 2, s.branchTeal],
  [660, 150, -0.62, 250, 20, 2, s.branchTeal],
  [1060, 140, -2.3, 230, 26, 2, s.trunkNavy],
  [1160, 110, -1.1, 210, 22, 2, s.trunkNavy],
  [1600, 330, -2.75, 270, 24, 2, s.trunkR],
  [1625, 230, -2.25, 230, 22, 2, s.trunkR],
  [1720, 120, -1.35, 170, 18, 2, s.trunkR],
  [1580, 430, -2.95, 210, 18, 1, s.trunkR],
  [1955, 180, -2.0, 170, 14, 2, s.far3],
];
const TWIGS = TWIG_ROOTS.map(([x, y, angle, length, width, depth, tone], k) => ({
  tone,
  d: twig(x, y, angle, length, width, depth, seeded(500 + k * 31))
    .map((line) => limb(line))
    .join(''),
}));

function Twigs() {
  return TWIGS.map((item, k) => <path key={k} className={item.tone} d={item.d} />);
}

/* Where the dragon swims behind the trees: the band across the middle, behind the navy trunk and
   the teal boughs (but not the S in front of them). The trees are drawn again over the dragon,
   clipped to it. */
const BEHIND: P[][] = [
  [
    [770, 960],
    [800, 820],
    [860, 640],
    [930, 480],
    [985, 300],
    [1440, 300],
    [1474, 470],
    [1456, 560],
    [1404, 640],
    [1330, 860],
    [1230, 940],
    [1150, 960],
  ],
];
const BEHIND_PATH = BEHIND.map(
  (polygon) => 'M' + polygon.map(([x, y]) => `${x} ${y}`).join('L') + 'Z',
).join('');

/* The ground: far hills behind the trees, near hills over their roots. */
const FAR_HILLS = [
  {
    d: smooth([
      [-40, 780],
      [80, 705],
      [210, 700],
      [320, 745],
      [340, 1120],
      [-40, 1120],
    ]),
    tone: s.hillFar,
  },
  {
    d: 'M640 1140C690 1040 780 990 880 988C960 990 1040 1030 1090 1090L1100 1160L640 1160Z',
    tone: s.hillFar,
  },
  {
    d: 'M1240 1110C1330 1010 1460 952 1590 955C1700 958 1820 985 1940 1000L1940 1130L1240 1130Z',
    tone: s.hillFar2,
  },
  {
    d: 'M1330 1060C1420 1020 1520 1000 1620 1004C1700 1008 1760 1020 1800 1040L1800 1080L1330 1090Z',
    tone: s.hillDeep,
  },
];

/* The hills on the left, behind the teal trunk but over the roots of the big one. */
const LEFT_HILLS = [
  {
    d: 'M-30 1012C80 992 220 984 330 998C420 1008 500 1030 560 1080L560 1600L-30 1600Z',
    tone: s.hillLight,
  },
  {
    d: 'M-30 1128C120 1104 300 1112 470 1150C520 1162 560 1180 600 1200L600 1600L-30 1600Z',
    tone: s.hillPale,
  },
  {
    d: 'M-30 1206C160 1180 360 1214 600 1264L600 1600L-30 1600Z',
    tone: s.hillMid,
  },
];

/* The ground in front of every tree: the slope behind the child, a pale band across it, a blue
   rise by the near trunk, and the dark hollows and the pale hump in the foreground. */
const GROUND = [
  {
    // The pale rise behind the tail.
    d: 'M1570 1000C1600 880 1650 760 1730 704C1800 664 1900 652 2040 650L2040 1110L1570 1110Z',
    tone: s.hillRise,
  },
  {
    d: 'M760 1270C800 1160 900 1112 1050 1102C1200 1094 1350 1112 1460 1090C1560 1060 1680 1030 1850 1045L2040 1080L2040 1520L760 1520Z',
    tone: s.hillMid,
  },
  {
    d: 'M880 1240C1000 1192 1200 1172 1380 1180C1520 1188 1640 1202 1760 1232L1800 1300C1600 1282 1300 1272 880 1302Z',
    tone: s.hillPale,
  },
  {
    d: 'M1470 1250C1520 1180 1620 1150 1760 1160L1790 1290C1680 1290 1560 1280 1470 1250Z',
    tone: s.hillBlue,
  },
  {
    d: 'M1250 1520C1330 1430 1500 1400 1720 1410L1720 1520Z',
    tone: s.hillDeep,
  },
  {
    d: 'M-30 1392C160 1360 380 1378 620 1416C600 1460 570 1500 560 1520L-30 1520Z',
    tone: s.hillDark,
  },
  {
    d: 'M-30 1450C100 1440 240 1452 340 1480L350 1520L-30 1520Z',
    tone: s.hillNight,
  },
  {
    d: 'M540 1520C580 1420 700 1352 850 1340C1010 1328 1160 1350 1270 1394C1340 1424 1400 1470 1430 1520Z',
    tone: s.hillLight,
  },
];

/** Brush texture for a hill: loose, nearly level sweeps across its box, pale, light and dark. */
function hillTexture(d: string, seed: number) {
  const numbers = d.match(/-?\d+(\.\d+)?/g)!.map(Number);
  const xs = numbers.filter((_, i) => i % 2 === 0);
  const ys = numbers.filter((_, i) => i % 2 === 1);
  const [x0, x1] = [Math.max(-30, Math.min(...xs)), Math.min(2030, Math.max(...xs))];
  const [y0, y1] = [Math.min(...ys), Math.min(1520, Math.max(...ys))];
  const random = seeded(seed);
  const count = Math.round(((x1 - x0) * (y1 - y0)) / 1500);
  let pale = '';
  let light = '';
  let dark = '';
  for (let k = 0; k < count; k++) {
    const x = x0 + random() * (x1 - x0);
    const y = y0 + random() * (y1 - y0);
    const length = 50 + random() * 200;
    const tilt = (random() - 0.5) * 0.14;
    const sag = (random() - 0.5) * 14;
    const stroke = `M${Math.round(x)} ${Math.round(y)}q${Math.round(length / 2)} ${Math.round(tilt * length + sag)} ${Math.round(length)} ${Math.round(tilt * length * 2)}`;
    const pick = random();
    if (pick < 0.18) pale += stroke;
    else if (pick < 0.62) light += stroke;
    else dark += stroke;
  }
  return { pale, light, dark };
}

/* Painted strokes on the ground. */
const GROUND_STROKES =
  'M40 1060q80 -8 150 -2M260 1046q60 -4 120 4M30 1160q90 -12 170 -6M60 1260q120 -10 220 6M120 1320q100 -6 180 4M720 1200q80 -20 170 -24M1000 1150q90 -8 170 -4M1180 1236q120 -6 230 14M1460 1120q60 -20 140 -26M1600 1330q60 -4 110 4M700 1400q120 -30 260 -30M880 1450q140 -20 300 6M1420 1440q90 -10 180 0';
const RIVER = 'M210 1146C300 1150 400 1160 470 1176';

/* The big dark trunk in the near right corner, and its grain. */
const NEAR_TRUNK = smooth([
  [1640, 1530],
  [1690, 1420],
  [1735, 1320],
  [1770, 1220],
  [1820, 1110],
  [1870, 1040],
  [1905, 950],
  [1942, 860],
  [1978, 790],
  [2030, 690],
  [2040, 1530],
]);
const NEAR_GRAIN =
  'M1730 1500C1760 1400 1800 1330 1850 1260C1890 1200 1900 1120 1950 1060M1800 1500C1830 1420 1870 1360 1910 1300C1950 1240 1960 1170 2010 1110M1880 1500C1890 1440 1930 1390 1960 1350M1760 1380C1790 1320 1830 1280 1860 1220M1900 1010C1930 960 1960 920 1990 870M1940 1180C1960 1140 1990 1120 2010 1080M1700 1480C1720 1440 1740 1410 1760 1380';
const NEAR_LIGHT =
  'M1780 1300q20 -30 40 -50M1850 1180q16 -30 34 -46M1920 1020q12 -24 30 -40M1980 900q10 -20 24 -34M1760 1440q14 -26 30 -40';

/* The reeds: dark blades and three cattails. */
const BLADES: W[][] = [
  [
    [905, 1335, 24],
    [872, 1230, 20],
    [852, 1120, 14],
    [846, 1020, 6],
    [850, 960, 2],
  ],
  [
    [915, 1335, 22],
    [962, 1200, 18],
    [1032, 1080, 13],
    [1110, 990, 7],
    [1182, 930, 2],
  ],
  [
    [925, 1335, 20],
    [1000, 1252, 16],
    [1100, 1182, 11],
    [1190, 1142, 6],
    [1262, 1120, 2],
  ],
  [
    [935, 1338, 18],
    [1030, 1292, 14],
    [1120, 1262, 9],
    [1205, 1250, 2],
  ],
  [
    [895, 1335, 20],
    [832, 1272, 14],
    [780, 1242, 8],
    [738, 1236, 2],
  ],
  [
    [915, 1335, 18],
    [942, 1250, 15],
    [1002, 1180, 10],
    [1062, 1140, 2],
  ],
  [
    [902, 1338, 16],
    [870, 1300, 12],
    [820, 1300, 6],
    [790, 1310, 2],
  ],
  [
    [500, 1336, 14],
    [430, 1270, 10],
    [370, 1230, 6],
    [320, 1214, 2],
  ],
];

/* The bare bushes in the lower left. */
const BUSH: W[][] = [
  [
    [286, 1440, 15],
    [276, 1330, 12],
    [266, 1230, 10],
    [262, 1150, 8],
    [270, 1085, 5],
  ],
  [
    [268, 1252, 8],
    [220, 1222, 7],
    [160, 1196, 5],
    [108, 1186, 3],
  ],
  [
    [266, 1292, 8],
    [310, 1242, 7],
    [330, 1172, 5],
    [346, 1120, 3],
  ],
  [
    [262, 1152, 6],
    [240, 1112, 4],
    [228, 1094, 3],
  ],
  [
    [318, 1214, 5],
    [348, 1192, 3],
  ],
  [
    [172, 1442, 12],
    [150, 1362, 10],
    [110, 1312, 7],
    [60, 1296, 5],
    [34, 1290, 3],
  ],
  [
    [150, 1362, 8],
    [190, 1302, 6],
    [202, 1242, 4],
    [196, 1202, 3],
  ],
  [
    [120, 1322, 5],
    [90, 1272, 3],
  ],
  [
    [232, 1442, 10],
    [230, 1382, 8],
    [214, 1322, 5],
  ],
  [
    [214, 1322, 4],
    [240, 1290, 3],
  ],
];

/** The drifting motes that hang in the air, where the painting puts them. Size is a radius. */
export const MOTES: { x: number; y: number; r: number }[] = [
  { x: 582, y: 118, r: 11 },
  { x: 597, y: 160, r: 6 },
  { x: 640, y: 100, r: 4 },
  { x: 700, y: 230, r: 6 },
  { x: 805, y: 190, r: 5 },
  { x: 752, y: 205, r: 4 },
  { x: 115, y: 390, r: 7 },
  { x: 140, y: 355, r: 4 },
  { x: 135, y: 233, r: 5 },
  { x: 1535, y: 448, r: 8 },
  { x: 1572, y: 402, r: 4 },
  { x: 1688, y: 560, r: 6 },
  { x: 1716, y: 638, r: 5 },
  { x: 1960, y: 572, r: 9 },
  { x: 1992, y: 598, r: 5 },
  { x: 1440, y: 915, r: 5 },
  { x: 1362, y: 978, r: 4 },
  { x: 1430, y: 1070, r: 4 },
  { x: 420, y: 960, r: 5 },
  { x: 510, y: 982, r: 4 },
  { x: 530, y: 1050, r: 7 },
  { x: 850, y: 905, r: 4 },
  { x: 856, y: 540, r: 4 },
  { x: 874, y: 600, r: 3 },
  { x: 950, y: 610, r: 4 },
  { x: 1268, y: 1306, r: 3 },
  { x: 1180, y: 1270, r: 3 },
];

/** A hill, with its brush texture kept inside it. */
function Hill({ id, d, tone, seed }: { id: string; d: string; tone: string; seed: number }) {
  const texture = hillTexture(d, seed);
  return (
    <>
      <clipPath id={id}>
        <path d={d} />
      </clipPath>
      <path className={tone} d={d} />
      <g clipPath={`url(#${id})`}>
        <path className={s.hillRim} d={d} />
        <path className={s.grassDark} d={texture.dark} />
        <path className={s.grassLight} d={texture.light} />
        <path className={s.grassPale} d={texture.pale} />
      </g>
    </>
  );
}

/** The sky, the far trees and hills, the trees, and the near hills: everything behind the dragon. */
export function BackScene({ id }: { id: string }) {
  return (
    <>
      <defs>
        <linearGradient id={`${id}-sky`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" className={s.skyTop} />
          <stop offset="0.36" className={s.skyMid} />
          <stop offset="0.52" className={s.skyLow} />
          <stop offset="1" className={s.skyLow} />
        </linearGradient>
        <radialGradient id={`${id}-haze`} cx="0.5" cy="0.5" r="0.5">
          <stop offset="0" className={s.hazeStop} />
          <stop offset="1" className={s.hazeStop} stopOpacity="0" />
        </radialGradient>
      </defs>
      <rect x="-200" y="-200" width="2400" height="1900" fill={`url(#${id}-sky)`} />
      <ellipse cx="1060" cy="820" rx="620" ry="320" fill={`url(#${id}-haze)`} />
      <Limbs limbs={FAR} seed={11} />
      {FAR_HILLS.map((hill, k) => (
        <path key={k} className={hill.tone} d={hill.d} />
      ))}
      <Twigs />
      <g id={`${id}-trees-left`}>
        <Limbs limbs={TREES_LEFT} seed={101} />
      </g>
      {LEFT_HILLS.map((hill, k) => (
        <Hill key={k} id={`${id}-l${k}`} d={hill.d} tone={hill.tone} seed={k * 31 + 5} />
      ))}
      <path className={s.river} d={RIVER} />
      <g id={`${id}-trees-right`}>
        <Limbs limbs={TREES_RIGHT} seed={211} />
      </g>
      {GROUND.map((hill, k) => (
        <Hill key={k} id={`${id}-g${k}`} d={hill.d} tone={hill.tone} seed={k * 37 + 9} />
      ))}
      <path className={s.groundStroke} d={GROUND_STROKES} />
    </>
  );
}

/** The trees again, only where the dragon passes behind them; then the near corner. */
export function FrontScene({ id }: { id: string }) {
  return (
    <>
      <defs>
        <clipPath id={`${id}-behind`}>
          <path d={BEHIND_PATH} />
        </clipPath>
        <radialGradient id={`${id}-lamp`} cx="0.5" cy="0.5" r="0.5">
          <stop offset="0" className={s.lampCore} />
          <stop offset="0.3" className={s.lampMid} />
          <stop offset="1" className={s.lampEdge} />
        </radialGradient>
        <radialGradient id={`${id}-pool`} cx="0.5" cy="0.5" r="0.5">
          <stop offset="0" className={s.lampPool} />
          <stop offset="1" className={s.lampEdge} />
        </radialGradient>
      </defs>
      <g clipPath={`url(#${id}-behind)`}>
        <use href={`#${id}-trees-left`} />
        <use href={`#${id}-trees-right`} />
      </g>

      <path className={s.nearTrunk} d={NEAR_TRUNK} />
      <path className={s.nearGrain} d={NEAR_GRAIN} />
      <path className={s.nearLight} d={NEAR_LIGHT} />

      {/* The bushes and the reeds. */}
      <g className={s.bush}>
        {BUSH.map((line, k) => (
          <path key={k} d={limb(line)} />
        ))}
      </g>
      <g className={s.reeds} data-part="reeds">
        {BLADES.map((line, k) => (
          <path key={k} className={k % 3 === 1 ? s.blade2 : s.blade} d={limb(line)} />
        ))}
        <path className={s.stem} d="M893 1336C892 1200 888 1080 884 940" />
        <ellipse className={s.cattail} cx="885" cy="1022" rx="15" ry="42" />
        <path
          className={s.stem}
          d="M930 1336C960 1270 1010 1210 1060 1170M1088 1150C1110 1136 1130 1126 1150 1120"
        />
        <ellipse
          className={s.cattail}
          cx="1072"
          cy="1160"
          rx="38"
          ry="14"
          transform="rotate(-28 1072 1160)"
        />
        <path className={s.stem} d="M480 1330C450 1300 420 1278 392 1264M336 1240L300 1226" />
        <ellipse
          className={s.cattail}
          cx="364"
          cy="1252"
          rx="34"
          ry="13"
          transform="rotate(22 364 1252)"
        />
      </g>

      {/* The lantern's light on the ground and the air, under the child. */}
      <ellipse className={s.pool} cx="1240" cy="1360" rx="150" ry="46" fill={`url(#${id}-pool)`} />

      {/* The child, head tipped back to look up, the lantern held out at arm's length. */}
      <g data-part="child">
        <path
          className={s.pants}
          d="M1302 1256L1366 1256L1370 1324L1390 1404L1368 1408L1344 1336L1330 1408L1306 1408L1302 1326Z"
        />
        <path className={s.pantsLight} d="M1305 1270L1304 1402" />
        <path
          className={s.shoe}
          d="M1302 1404L1332 1404L1336 1416L1298 1416ZM1366 1402L1392 1400L1398 1412L1364 1416Z"
        />
        <path
          className={s.hood}
          d="M1308 1148C1318 1142 1334 1140 1348 1144C1362 1150 1368 1168 1368 1194C1368 1220 1367 1242 1366 1260L1300 1262C1298 1230 1298 1194 1302 1168Z"
        />
        <path
          className={s.hoodStripe}
          d="M1301 1186L1368 1182L1368 1192L1300 1196ZM1300 1210L1368 1206L1367 1216L1299 1220ZM1299 1234L1367 1230L1367 1240L1299 1244Z"
        />
        {/* The hood, hanging down the back, and its drawstring. */}
        <path
          className={s.hoodFlap}
          d="M1336 1138C1352 1134 1368 1144 1374 1162C1380 1180 1376 1200 1366 1210C1362 1194 1356 1174 1342 1158Z"
        />
        <path className={s.string} d="M1340 1152C1342 1166 1344 1178 1342 1192" />
        {/* The arm, reaching out level with the shoulder. */}
        <path
          className={s.hood}
          d="M1326 1160C1300 1178 1270 1196 1240 1204L1236 1226C1270 1222 1302 1208 1330 1194Z"
        />
        <path
          className={s.hoodStripe}
          d="M1296 1184L1304 1204L1294 1208L1286 1188ZM1270 1196L1276 1214L1266 1216L1260 1198Z"
        />
        <circle className={s.skin} cx="1229" cy="1216" r="10" />
        {/* The head, tipped back: the face in profile, looking up and to the left. */}
        <path
          className={s.skin}
          d="M1326 1092C1318 1094 1312 1100 1309 1108L1303 1114L1309 1118C1308 1124 1310 1130 1316 1136L1324 1150L1340 1148L1340 1100Z"
        />
        <path
          className={s.hair}
          d="M1320 1094C1326 1084 1342 1082 1354 1088C1376 1096 1398 1094 1428 1098C1412 1106 1400 1108 1388 1110C1404 1114 1418 1122 1424 1130C1404 1130 1388 1126 1374 1120C1380 1130 1390 1140 1394 1150C1374 1144 1358 1134 1348 1126C1344 1138 1340 1146 1334 1150L1332 1112C1328 1104 1324 1098 1320 1094Z"
        />
        {/* The lantern, swinging a little from the hand. */}
        <g className={s.lantern} data-part="lantern">
          <path className={s.lampWire} d="M1228 1222L1226 1240" />
          <circle className={s.lampGlow} cx="1226" cy="1288" r="92" fill={`url(#${id}-lamp)`} />
          <path className={s.lampCap} d="M1226 1236L1258 1256L1194 1256Z" />
          <rect className={s.lampCap} x="1198" y="1254" width="56" height="8" rx="2" />
          <rect className={s.lampGlass} x="1204" y="1262" width="44" height="50" rx="6" />
          <path
            className={s.lampSpots}
            d="M1212 1274q5 -5 9 0q-4 5 -9 0ZM1232 1284q6 -4 9 1q-5 5 -9 -1ZM1214 1298q5 -5 10 0q-5 5 -10 0ZM1236 1300q4 -4 8 1q-4 4 -8 -1Z"
          />
          <rect className={s.lampCap} x="1208" y="1310" width="36" height="7" rx="2" />
          <path className={s.lampStem} d="M1226 1317L1226 1360" />
        </g>
      </g>

      {/* The fox, head up and ears pricked, its tail flicking now and then. */}
      <g data-part="fox">
        <path
          className={s.fox}
          d="M1468 1388L1484 1378L1490 1358L1498 1372L1506 1356L1510 1376C1518 1388 1534 1396 1556 1398C1572 1398 1586 1402 1594 1410L1598 1428L1590 1454L1580 1454L1584 1430C1572 1432 1556 1432 1544 1430L1534 1456L1524 1456L1528 1428C1520 1424 1514 1420 1510 1414L1504 1448L1494 1448L1498 1410C1492 1402 1486 1398 1478 1396C1472 1394 1468 1392 1468 1388Z"
        />
        <path
          className={`${s.fox} ${s.foxTail}`}
          d="M1590 1406C1606 1412 1616 1426 1624 1448C1614 1444 1602 1434 1592 1426Z"
        />
      </g>
    </>
  );
}
```

### spirit-dragon/geometry.ts

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

/*
 * Drawing helpers for the spirit dragon's forest, all in the scene's own units (a 2000 × 1500
 * painting):
 *
 * - smooth() turns a list of points into a soft curve through them (Catmull-Rom as cubic Béziers),
 *   so a hand-placed outline reads as painted rather than cut.
 * - limb() draws a trunk or a branch from its middle line and its width at each point, tapering to
 *   a round tip, and brushStrokes() lays the painterly streaks along it, so no clip path is needed
 *   to keep them inside.
 * - Track is the dragon's closed swimming loop: a centripetal Catmull-Rom spline through its
 *   control points, resampled every few units so a point at any distance along it costs two
 *   look-ups.
 */

export type P = readonly [number, number];
/** A middle-line point of a limb, and the limb's width there. */
export type W = readonly [number, number, number];

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

/** A smooth path through `points`: closed, or open from the first to the last. */
export function smooth(points: readonly P[], closed = true): string {
  const n = points.length;
  if (n < 2) return '';
  const at = (i: number) =>
    closed ? points[(i + n) % n] : points[Math.max(0, Math.min(n - 1, i))];
  let d = `M${r1(points[0][0])} ${r1(points[0][1])}`;
  const last = closed ? n : n - 1;
  for (let i = 0; i < last; i++) {
    const p0 = at(i - 1);
    const p1 = at(i);
    const p2 = at(i + 1);
    const p3 = at(i + 2);
    const c1x = p1[0] + (p2[0] - p0[0]) / 6;
    const c1y = p1[1] + (p2[1] - p0[1]) / 6;
    const c2x = p2[0] - (p3[0] - p1[0]) / 6;
    const c2y = p2[1] - (p3[1] - p1[1]) / 6;
    d += `C${r1(c1x)} ${r1(c1y)} ${r1(c2x)} ${r1(c2y)} ${r1(p2[0])} ${r1(p2[1])}`;
  }
  return closed ? d + 'Z' : d;
}

/** The unit normal of a middle line at point i (to its left, walking from first to last). */
function normalAt(line: readonly W[], i: number): P {
  const a = line[Math.max(0, i - 1)];
  const b = line[Math.min(line.length - 1, i + 1)];
  const dx = b[0] - a[0];
  const dy = b[1] - a[1];
  const length = Math.hypot(dx, dy) || 1;
  return [-dy / length, dx / length];
}

/** The outline of a limb: both sides of its middle line, closed by a round tip. */
export function limb(line: readonly W[]): string {
  const left: P[] = [];
  const right: P[] = [];
  line.forEach((point, i) => {
    const [nx, ny] = normalAt(line, i);
    const half = point[2] / 2;
    left.push([point[0] + nx * half, point[1] + ny * half]);
    right.push([point[0] - nx * half, point[1] - ny * half]);
  });
  const end = line[line.length - 1];
  const before = line[line.length - 2];
  const length = Math.hypot(end[0] - before[0], end[1] - before[1]) || 1;
  const tip: P = [
    end[0] + ((end[0] - before[0]) / length) * end[2] * 0.45,
    end[1] + ((end[1] - before[1]) / length) * end[2] * 0.45,
  ];
  return smooth([...left, tip, ...right.reverse()]);
}

/** One band of a limb, from `from` to `to` across it (−0.5 is its left edge, 0.5 its right). */
export function limbBand(line: readonly W[], from: number, to: number): string {
  const a: P[] = [];
  const b: P[] = [];
  line.forEach((point, i) => {
    const [nx, ny] = normalAt(line, i);
    // Thin out towards the tip, so the band never pokes past the limb's round end.
    const w = point[2] * (i === line.length - 1 ? 0.7 : 1);
    a.push([point[0] - nx * w * from, point[1] - ny * w * from]);
    b.push([point[0] - nx * w * to, point[1] - ny * w * to]);
  });
  return smooth([...a, ...b.reverse()]);
}

/** A small seeded random generator, so the painting is the same on every load. */
export function seeded(seed: number) {
  let state = seed >>> 0;
  return () => {
    state = (state + 0x6d2b79f5) >>> 0;
    let t = state;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

/**
 * Brush streaks along a limb: `count` short lines that follow its grain, kept inside its width.
 * Returns one path for all of them.
 */
export function brushStrokes(
  line: readonly W[],
  count: number,
  seed: number,
  reach = 0.4,
  scale = 1,
) {
  const random = seeded(seed);
  // The middle line's length, to place streaks evenly along it.
  const lengths = [0];
  for (let i = 1; i < line.length; i++)
    lengths.push(
      lengths[i - 1] + Math.hypot(line[i][0] - line[i - 1][0], line[i][1] - line[i - 1][1]),
    );
  const total = lengths[lengths.length - 1];
  let d = '';
  for (let k = 0; k < count; k++) {
    const s = (0.04 + random() * 0.92) * total;
    let i = 1;
    while (i < line.length - 1 && lengths[i] < s) i++;
    const f = (s - lengths[i - 1]) / (lengths[i] - lengths[i - 1] || 1);
    const a = line[i - 1];
    const b = line[i];
    const x = a[0] + (b[0] - a[0]) * f;
    const y = a[1] + (b[1] - a[1]) * f;
    const w = a[2] + (b[2] - a[2]) * f;
    const segment = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
    const tx = (b[0] - a[0]) / segment;
    const ty = (b[1] - a[1]) / segment;
    const across = (random() * 2 - 1) * reach * w;
    const length = (20 + random() * Math.min(110, w * 0.9)) * scale;
    const bend = (random() - 0.5) * 6;
    const sx = x - ty * across;
    const sy = y + tx * across;
    d += `M${r1(sx)} ${r1(sy)}q${r1(tx * length * 0.5 - ty * bend)} ${r1(ty * length * 0.5 + tx * bend)} ${r1(tx * length)} ${r1(ty * length)}`;
  }
  return d;
}

/** The dragon's loop: points at any distance along a closed spline. */
export class Track {
  readonly length: number;
  /** The distance along the loop of each control point. */
  readonly marks: number[];
  private readonly xs: Float64Array;
  private readonly ys: Float64Array;
  private readonly step: number;

  constructor(controls: readonly P[], step = 4) {
    const n = controls.length;
    const dense: number[] = [];
    const denseMarks: number[] = [];
    const at = (i: number) => controls[(i + n) % n];
    for (let k = 0; k < n; k++) {
      denseMarks.push(dense.length / 2);
      const [p0, p1, p2, p3] = [at(k - 1), at(k), at(k + 1), at(k + 2)];
      // Centripetal parametrisation: no loops or cusps where the points bunch up.
      const t1 = Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) ** 0.5 || 1e-3;
      const t2 = t1 + (Math.hypot(p2[0] - p1[0], p2[1] - p1[1]) ** 0.5 || 1e-3);
      const t3 = t2 + (Math.hypot(p3[0] - p2[0], p3[1] - p2[1]) ** 0.5 || 1e-3);
      for (let j = 0; j < 48; j++) {
        const t = t1 + ((t2 - t1) * j) / 48;
        const point = [0, 1].map((c) => {
          const a1 = ((t1 - t) / t1) * p0[c] + (t / t1) * p1[c];
          const a2 = ((t2 - t) / (t2 - t1)) * p1[c] + ((t - t1) / (t2 - t1)) * p2[c];
          const a3 = ((t3 - t) / (t3 - t2)) * p2[c] + ((t - t2) / (t3 - t2)) * p3[c];
          const b1 = ((t2 - t) / t2) * a1 + (t / t2) * a2;
          const b2 = ((t3 - t) / (t3 - t1)) * a2 + ((t - t1) / (t3 - t1)) * a3;
          return ((t2 - t) / (t2 - t1)) * b1 + ((t - t1) / (t2 - t1)) * b2;
        });
        dense.push(point[0], point[1]);
      }
    }
    // Lengths along the dense line, then even resampling.
    const count = dense.length / 2;
    const cumulative = new Float64Array(count + 1);
    for (let i = 1; i <= count; i++) {
      const a = (i - 1) * 2;
      const b = (i % count) * 2;
      cumulative[i] =
        cumulative[i - 1] + Math.hypot(dense[b] - dense[a], dense[b + 1] - dense[a + 1]);
    }
    this.length = cumulative[count];
    this.marks = denseMarks.map((i) => cumulative[i]);
    this.step = step;
    const samples = Math.ceil(this.length / step);
    this.xs = new Float64Array(samples + 1);
    this.ys = new Float64Array(samples + 1);
    let j = 0;
    for (let i = 0; i <= samples; i++) {
      const s = Math.min(i * step, this.length);
      while (j < count - 1 && cumulative[j + 1] < s) j++;
      const f = (s - cumulative[j]) / (cumulative[j + 1] - cumulative[j] || 1);
      const a = j * 2;
      const b = ((j + 1) % count) * 2;
      this.xs[i] = dense[a] + (dense[b] - dense[a]) * f;
      this.ys[i] = dense[a + 1] + (dense[b + 1] - dense[a + 1]) * f;
    }
  }

  /** The point `s` units along the loop (any `s`: it wraps), written into `out`. */
  point(s: number, out: { x: number; y: number }) {
    const wrapped = ((s % this.length) + this.length) % this.length;
    const f = wrapped / this.step;
    const i = Math.floor(f);
    const k = f - i;
    const j = Math.min(i + 1, this.xs.length - 1);
    out.x = this.xs[i] + (this.xs[j] - this.xs[i]) * k;
    out.y = this.ys[i] + (this.ys[j] - this.ys[i]) * k;
    return out;
  }
}

/**
 * Knot holes in the bark: `count` dark almonds laid along a limb's grain, each with a pale lip on
 * one side. Returns the holes and the lips as two paths.
 */
export function knotHoles(line: readonly W[], count: number, seed: number) {
  const random = seeded(seed);
  const lengths = [0];
  for (let i = 1; i < line.length; i++)
    lengths.push(
      lengths[i - 1] + Math.hypot(line[i][0] - line[i - 1][0], line[i][1] - line[i - 1][1]),
    );
  const total = lengths[lengths.length - 1];
  let hole = '';
  let lip = '';
  for (let k = 0; k < count; k++) {
    const s = (0.08 + random() * 0.84) * total;
    let i = 1;
    while (i < line.length - 1 && lengths[i] < s) i++;
    const f = (s - lengths[i - 1]) / (lengths[i] - lengths[i - 1] || 1);
    const a = line[i - 1];
    const b = line[i];
    const w = a[2] + (b[2] - a[2]) * f;
    const segment = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
    const tx = (b[0] - a[0]) / segment;
    const ty = (b[1] - a[1]) / segment;
    const across = (random() * 2 - 1) * 0.28 * w;
    const cx = a[0] + (b[0] - a[0]) * f - ty * across;
    const cy = a[1] + (b[1] - a[1]) * f + tx * across;
    const size = Math.min(1.4, Math.max(0.35, w / 160));
    const half = (14 + random() * 26) * size;
    const thick = (4 + random() * 7) * size;
    const side = random() < 0.5 ? -1 : 1;
    const ax = cx - tx * half;
    const ay = cy - ty * half;
    const bx = cx + tx * half;
    const by = cy + ty * half;
    const nx = -ty * thick * 2;
    const ny = tx * thick * 2;
    // Lopsided, so it reads as a hollow rather than an eye.
    const lean = (random() - 0.5) * half;
    const soft = 0.4 + random() * 0.5;
    hole += `M${r1(ax)} ${r1(ay)}Q${r1(cx + nx + tx * lean)} ${r1(cy + ny + ty * lean)} ${r1(bx)} ${r1(by)}Q${r1(cx - nx * soft - tx * lean)} ${r1(cy - ny * soft - ty * lean)} ${r1(ax)} ${r1(ay)}Z`;
    lip += `M${r1(ax - tx * 3)} ${r1(ay - ty * 3)}Q${r1(cx + nx * side * 1.5)} ${r1(cy + ny * side * 1.5)} ${r1(bx + tx * 3)} ${r1(by + ty * 3)}`;
  }
  return { hole, lip };
}

/**
 * A bare twig and its forks: a limb from (x, y) at `angle` (radians, 0 pointing right), `length`
 * long and `width` wide at its base, forking `depth` more times. Returns the limbs' middle lines.
 */
export function twig(
  x: number,
  y: number,
  angle: number,
  length: number,
  width: number,
  depth: number,
  random: () => number,
  out: W[][] = [],
): W[][] {
  const steps = 4;
  const line: W[] = [];
  let a = angle;
  let px = x;
  let py = y;
  for (let k = 0; k <= steps; k++) {
    line.push([px, py, Math.max(2, width * (1 - (0.6 * k) / steps))]);
    a += (random() - 0.5) * 0.35;
    px += (Math.cos(a) * length) / steps;
    py += (Math.sin(a) * length) / steps;
  }
  out.push(line);
  if (depth > 0) {
    const forks = 1 + Math.round(random());
    for (let f = 0; f < forks; f++) {
      const at = line[1 + Math.floor(random() * (steps - 1))];
      const turn = (0.35 + random() * 0.5) * (f % 2 ? 1 : -1) * (random() < 0.3 ? -1 : 1);
      twig(
        at[0],
        at[1],
        angle + turn,
        length * (0.5 + random() * 0.2),
        at[2] * 0.75,
        depth - 1,
        random,
        out,
      );
    }
  }
  return out;
}
```

### spirit-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, playing: visible && !reduced && !taken };
}
```

### spirit-dragon/spirit-dragon.module.css

```css
/* Spirit dragon (spirit-dragon.tsx): a painted forest at dusk, 4:3, cropped to the stage. By day a
   misty grey-pink sky over teal trees and hills; at night a violet dusk, the trees deeper, the
   dragon and the lantern brighter against them.

   Colours are the tokens below, light-dark(light, dark): the component takes the color-scheme it
   inherits from the page (light unless the page says otherwise), or the one its colorScheme prop
   sets (data-color-scheme). The painting's paths take them through classes.

   Memory. The stage is one layout boundary sized from its width. The painting behind the dragon,
   the one in front and the grain are still layers of their own, so the dragon's layer is the only
   one painted again on each frame; the mist moves by transforms on small layers. */

.root {
  /* The sky, top to bottom, and the pink haze low in it. */
  --sky-top: #acbfc2;
  --sky-mid: #b3bfc2;
  --sky-low: #c5b6bb;
  --haze: #d5bec1;
  /* The far trees, pale with distance. */
  --far-1: #47827d;
  --far-2: #327973;
  --far-2-shade: #2a6e68;
  --far-3: #376180;
  /* The trees the dragon weaves between, and their shaded sides. */
  --trunk-blue: #325c70;
  --trunk-blue-shade: #204056;
  --trunk-navy: #27435a;
  --trunk-navy-shade: #1b3148;
  --trunk-teal: #0b515e;
  --branch-teal: #125c64;
  --branch-teal-shade: #0b4c56;
  --limb-teal: #2f7372;
  --limb-teal-shade: #205e61;
  --trunk-r: #1f4b60;
  --trunk-r-light: #265f6c;
  /* Brush streaks on the bark: light, dark and rust. */
  --streak-light: rgb(190 225 228 / 0.35);
  --streak-dark: rgb(10 30 50 / 0.35);
  --streak-rust: rgb(176 106 80 / 0.5);
  --knot: rgb(10 24 42 / 0.82);
  --knot-lip: rgb(150 196 200 / 0.3);
  --rust-wide: rgb(160 96 74 / 0.22);
  /* The hills, far to near. */
  --hill-far: #4f8080;
  --hill-far-2: #296276;
  --hill-deep: #296271;
  --hill-light: #417477;
  --hill-pale: #4f8484;
  --hill-mid: #376d74;
  --hill-blue: #205261;
  --hill-dark: #23535e;
  --hill-rise: #5a97a2;
  --hill-night: #1a4856;
  --ground-stroke: rgb(170 214 212 / 0.28);
  --river: #2d5f8a;
  --grass-light: rgb(176 218 216 / 0.2);
  --grass-dark: rgb(18 58 82 / 0.22);
  --grass-pale: rgb(196 230 226 / 0.14);
  --hill-rim: rgb(190 228 224 / 0.16);
  /* The near trunk, the bushes and the reeds. */
  --near-trunk: #142846;
  --near-grain: #22406a;
  --near-light: rgb(120 150 190 / 0.35);
  --bush: #0f2a38;
  --blade: #203a52;
  --blade-2: #2b4d68;
  --cattail: #2f6d92;
  --fox: #163a4d;
  /* The child. */
  --hood: #d4594a;
  --hood-stripe: #ec7a62;
  --hood-flap: #b9473d;
  --pants: #3a170d;
  --pants-light: #b8613f;
  --hair: #111f26;
  --skin: #f1c4a6;
  --shoe: #160d0a;
  /* The lantern and its light. */
  --lamp-cap: #1c2731;
  --lamp-glass: #f9e48f;
  --lamp-spots: #e3963b;
  --lamp-core: #fff6c8;
  --lamp-mid: #ffd877;
  --lamp-stem: #7a2a22;
  /* The dragon: its body, its scales, and its glow. */
  --spirit: rgb(240 248 249);
  --spirit-body: rgb(236 246 248 / 0.66);
  --spirit-scale: rgb(150 190 200 / 0.5);
  --spirit-strand: rgb(170 206 214 / 0.75);
  --spirit-head: rgb(242 249 250 / 0.74);
  --spirit-eye: rgb(255 255 255);
  --spirit-eye-glow: rgb(200 240 250 / 0.45);
  --spirit-glow: rgb(255 255 255 / 0.9);
  --spirit-haze: rgb(235 248 250 / 0.5);
  --probe: #000;
  --mote: rgb(255 255 255 / 0.92);
  --mist: rgb(222 206 212 / 0.22);
  --grain: 0.46;
  /* The play button. */
  --control: rgb(12 30 42 / 0.45);
  --control-hover: rgb(12 30 42 / 0.62);
  --control-ink: #fff;
  --focus: #fff;
  position: relative;
  container-type: inline-size;
  contain: layout;
  width: 100%;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--sky-mid);
  isolation: isolate;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --sky-top: light-dark(#acbfc2, #1c2b3b);
    --sky-mid: light-dark(#b3bfc2, #24324a);
    --sky-low: light-dark(#c5b6bb, #43394f);
    --haze: light-dark(#d5bec1, #5a4560);
    --far-1: light-dark(#47827d, #234d55);
    --far-2: light-dark(#327973, #1f5752);
    --far-2-shade: light-dark(#2a6e68, #184844);
    --far-3: light-dark(#376180, #1f3a55);
    --trunk-blue: light-dark(#325c70, #1d3a4f);
    --trunk-blue-shade: light-dark(#204056, #132a3c);
    --trunk-navy: light-dark(#27435a, #172b40);
    --trunk-navy-shade: light-dark(#1b3148, #0e1d2f);
    --trunk-teal: light-dark(#0b515e, #083d47);
    --branch-teal: light-dark(#125c64, #0c434b);
    --branch-teal-shade: light-dark(#0b4c56, #08363e);
    --limb-teal: light-dark(#2f7372, #1d5254);
    --limb-teal-shade: light-dark(#205e61, #143f43);
    --trunk-r: light-dark(#1f4b60, #153349);
    --trunk-r-light: light-dark(#265f6c, #19454f);
    --streak-light: light-dark(rgb(190 225 228 / 0.35), rgb(150 200 210 / 0.2));
    --streak-dark: light-dark(rgb(10 30 50 / 0.35), rgb(0 8 20 / 0.4));
    --streak-rust: light-dark(rgb(176 106 80 / 0.5), rgb(130 76 64 / 0.45));
    --knot: light-dark(rgb(10 24 42 / 0.82), rgb(2 8 18 / 0.85));
    --knot-lip: light-dark(rgb(150 196 200 / 0.3), rgb(110 160 180 / 0.2));
    --rust-wide: light-dark(rgb(160 96 74 / 0.22), rgb(120 70 60 / 0.22));
    --hill-far: light-dark(#4f8080, #2d5961);
    --hill-far-2: light-dark(#296276, #1c4656);
    --hill-deep: light-dark(#296271, #183e4c);
    --hill-light: light-dark(#417477, #2f6266);
    --hill-pale: light-dark(#4f8484, #376c6d);
    --hill-mid: light-dark(#376d74, #275860);
    --hill-blue: light-dark(#205261, #163f52);
    --hill-dark: light-dark(#23535e, #163c48);
    --hill-rise: light-dark(#5a97a2, #2a5560);
    --hill-night: light-dark(#1a4856, #0a2733);
    --ground-stroke: light-dark(rgb(170 214 212 / 0.28), rgb(120 170 180 / 0.18));
    --river: light-dark(#2d5f8a, #1a3b5c);
    --grass-light: light-dark(rgb(176 218 216 / 0.2), rgb(120 170 180 / 0.14));
    --grass-dark: light-dark(rgb(18 58 82 / 0.22), rgb(0 12 24 / 0.3));
    --grass-pale: light-dark(rgb(196 230 226 / 0.14), rgb(140 190 200 / 0.1));
    --hill-rim: light-dark(rgb(190 228 224 / 0.16), rgb(130 180 190 / 0.1));
    --near-trunk: light-dark(#142846, #0a162b);
    --near-grain: light-dark(#22406a, #142a48);
    --near-light: light-dark(rgb(120 150 190 / 0.35), rgb(100 130 180 / 0.25));
    --bush: light-dark(#0f2a38, #071722);
    --blade: light-dark(#203a52, #10223a);
    --blade-2: light-dark(#2b4d68, #17304a);
    --cattail: light-dark(#2f6d92, #1f4d6e);
    --fox: light-dark(#163a4d, #0c2433);
    --hood: light-dark(#d4594a, #b8493d);
    --hood-stripe: light-dark(#ec7a62, #cf6650);
    --hood-flap: light-dark(#b9473d, #9a3a32);
    --pants: light-dark(#3a170d, #2c120b);
    --pants-light: light-dark(#b8613f, #8a4a32);
    --hair: light-dark(#111f26, #0a141a);
    --skin: light-dark(#f1c4a6, #e0ad90);
    --spirit-body: light-dark(rgb(236 246 248 / 0.66), rgb(226 242 250 / 0.6));
    --spirit-scale: light-dark(rgb(150 190 200 / 0.5), rgb(140 190 215 / 0.55));
    --spirit-strand: light-dark(rgb(170 206 214 / 0.75), rgb(150 200 225 / 0.7));
    --spirit-glow: light-dark(rgb(255 255 255 / 0.9), rgb(214 240 255 / 0.95));
    --spirit-haze: light-dark(rgb(235 248 250 / 0.5), rgb(170 220 255 / 0.6));
    --spirit-head: light-dark(rgb(242 249 250 / 0.74), rgb(236 248 255 / 0.74));
    --spirit-eye-glow: light-dark(rgb(200 240 250 / 0.45), rgb(150 220 255 / 0.6));
    --probe: light-dark(#000, #fff);
    --mist: light-dark(rgb(222 206 212 / 0.22), rgb(120 108 150 / 0.2));
    --control: light-dark(rgb(12 30 42 / 0.45), rgb(0 0 0 / 0.45));
    --control-hover: light-dark(rgb(12 30 42 / 0.62), rgb(0 0 0 / 0.62));
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

.stage {
  position: relative;
  aspect-ratio: 4 / 3;
  width: 100%;
}
.layer,
.grain {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: hidden;
  /* Each its own layer: the still ones are painted once. */
  will-change: transform;
}
.grain {
  mix-blend-mode: overlay;
  opacity: var(--grain);
  pointer-events: none;
}

/* The painting's colours. */
.skyTop {
  stop-color: var(--sky-top);
}
.skyMid {
  stop-color: var(--sky-mid);
}
.skyLow {
  stop-color: var(--sky-low);
}
.hazeStop {
  stop-color: var(--haze);
}
.far1 {
  fill: var(--far-1);
}
.far2 {
  fill: var(--far-2);
}
.far2Shade {
  fill: var(--far-2-shade);
}
.far3 {
  fill: var(--far-3);
}
.trunkBlue {
  fill: var(--trunk-blue);
}
.trunkBlueShade {
  fill: var(--trunk-blue-shade);
}
.trunkNavy {
  fill: var(--trunk-navy);
}
.trunkNavyShade {
  fill: var(--trunk-navy-shade);
}
.trunkTeal {
  fill: var(--trunk-teal);
}
.branchTeal {
  fill: var(--branch-teal);
}
.branchTealShade {
  fill: var(--branch-teal-shade);
}
.limbTeal {
  fill: var(--limb-teal);
}
.limbTealShade {
  fill: var(--limb-teal-shade);
}
.trunkR {
  fill: var(--trunk-r);
}
.trunkRLight {
  fill: var(--trunk-r-light);
}
.streakLight,
.streakDark,
.streakRust {
  fill: none;
  stroke-linecap: round;
  stroke-width: 2.5;
}
.streakLight {
  stroke: var(--streak-light);
  stroke-width: 1.6;
}
.knot {
  fill: var(--knot);
}
.knotLip {
  fill: none;
  stroke: var(--knot-lip);
  stroke-width: 5;
  stroke-linecap: round;
}
.rustWide {
  fill: none;
  stroke: var(--rust-wide);
  stroke-width: 7;
  stroke-linecap: round;
}
.streakDark {
  stroke: var(--streak-dark);
  stroke-width: 4;
}
.streakRust {
  stroke: var(--streak-rust);
  stroke-width: 3;
}
.hillFar {
  fill: var(--hill-far);
}
.hillFar2 {
  fill: var(--hill-far-2);
}
.hillDeep {
  fill: var(--hill-deep);
}
.hillLight {
  fill: var(--hill-light);
}
.hillPale {
  fill: var(--hill-pale);
}
.hillMid {
  fill: var(--hill-mid);
}
.hillBlue {
  fill: var(--hill-blue);
}
.hillDark {
  fill: var(--hill-dark);
}
.hillRise {
  fill: var(--hill-rise);
}
.hillNight {
  fill: var(--hill-night);
}
.groundStroke {
  fill: none;
  stroke: var(--ground-stroke);
  stroke-width: 4;
  stroke-linecap: round;
}
.grassLight,
.grassDark,
.grassPale {
  fill: none;
  stroke-width: 3;
  stroke-linecap: round;
}
.grassPale {
  stroke: var(--grass-pale);
  stroke-width: 5;
}
.hillRim {
  fill: none;
  stroke: var(--hill-rim);
  stroke-width: 36;
}
.grassLight {
  stroke: var(--grass-light);
}
.grassDark {
  stroke: var(--grass-dark);
}
.river {
  fill: none;
  stroke: var(--river);
  stroke-width: 14;
  stroke-linecap: round;
}
.nearTrunk {
  fill: var(--near-trunk);
}
.nearGrain {
  fill: none;
  stroke: var(--near-grain);
  stroke-width: 10;
  stroke-linecap: round;
}
.nearLight {
  fill: none;
  stroke: var(--near-light);
  stroke-width: 3;
  stroke-linecap: round;
}
.bush {
  fill: var(--bush);
}
.blade {
  fill: var(--blade);
}
.blade2 {
  fill: var(--blade-2);
}
.stem {
  fill: none;
  stroke: var(--blade);
  stroke-width: 6;
  stroke-linecap: round;
}
.cattail {
  fill: var(--cattail);
}
.fox {
  fill: var(--fox);
}
.hair {
  fill: var(--hair);
}
.skin {
  fill: var(--skin);
}
.hood {
  fill: var(--hood);
}
.hoodFlap {
  fill: var(--hood-flap);
}
.hoodStripe {
  fill: var(--hood-stripe);
}
.pants {
  fill: var(--pants);
}
.string {
  fill: none;
  stroke: var(--hood-stripe);
  stroke-width: 3;
  stroke-linecap: round;
}
.pantsLight {
  fill: none;
  stroke: var(--pants-light);
  stroke-width: 3;
}
.shoe {
  fill: var(--shoe);
}
.lampWire {
  fill: none;
  stroke: var(--lamp-cap);
  stroke-width: 3;
}
.lampCap {
  fill: var(--lamp-cap);
}
.lampGlass {
  fill: var(--lamp-glass);
}
.lampSpots {
  fill: var(--lamp-spots);
}
.lampStem {
  fill: none;
  stroke: var(--lamp-stem);
  stroke-width: 4;
  stroke-linecap: round;
}
.lampCore {
  stop-color: var(--lamp-core);
  stop-opacity: 0.9;
}
.lampMid {
  stop-color: var(--lamp-mid);
  stop-opacity: 0.4;
}
.lampPool {
  stop-color: var(--lamp-mid);
  stop-opacity: 0.4;
}
.lampEdge {
  stop-color: var(--lamp-mid);
  stop-opacity: 0;
}
.mote,
.shed {
  fill: var(--mote);
}

/* The dragon. */
.dragon {
  filter: drop-shadow(0 0 calc(0.35cqw * var(--glow)) var(--spirit-glow))
    drop-shadow(0 0 calc(1.4cqw * var(--glow)) var(--spirit-haze));
}
/* A soft glow around the body: a wide faint stroke under it. */
.halo {
  fill: none;
  stroke: var(--spirit-haze);
  stroke-width: 34;
  stroke-linejoin: round;
  opacity: calc(0.5 * var(--glow));
}
.body {
  fill: var(--spirit-body);
  stroke: var(--spirit);
  stroke-width: 3;
  stroke-linejoin: round;
}
/* The bright bands just inside both edges. */
.rims {
  fill: none;
  stroke: var(--spirit);
  stroke-width: 9;
  stroke-linecap: round;
  opacity: 0.55;
}
.scales {
  fill: none;
  stroke: var(--spirit-scale);
  stroke-width: 2;
  stroke-linecap: round;
}
.lines,
.frills,
.whiskers {
  fill: none;
  stroke: var(--spirit);
  stroke-width: 2.5;
  stroke-linecap: round;
  opacity: 0.7;
}
.whiskers {
  stroke-width: 2.6;
  opacity: 0.9;
}
.maneMass {
  fill: var(--spirit);
  opacity: 0.72;
}
.maneLocks {
  fill: var(--spirit);
  opacity: 0.6;
}
/* Strands over the mane, a shade darker than it, so they show. */
.maneHair {
  fill: none;
  stroke: var(--spirit-strand);
  stroke-width: 2.4;
  stroke-linecap: round;
}
.fan {
  fill: var(--spirit);
  opacity: 0.22;
}
.fanRibs {
  fill: var(--spirit);
  opacity: 0.92;
}
.head {
  fill: var(--spirit-head);
}
.face {
  fill: none;
  stroke: var(--spirit-scale);
  stroke-width: 2.5;
  stroke-linecap: round;
}
.eye,
.eyeGlow {
  fill: none;
  stroke-linecap: round;
}
.eye {
  stroke: var(--spirit-eye);
  stroke-width: 4;
}
.eyeGlow {
  stroke: var(--spirit-eye-glow);
  stroke-width: 9;
}
.crest {
  fill: var(--spirit);
  opacity: 0.7;
}

/* The colour scheme the component is drawn in, read by the script (see useScheme). */
.probe {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
  color: var(--probe);
  pointer-events: none;
}

/* The mist: three soft banks drifting slowly across. */
.mist {
  position: absolute;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
}
.mist span {
  position: absolute;
  border-radius: 50%;
  background: radial-gradient(closest-side, var(--mist), transparent);
  will-change: transform;
  animation: drift calc(26s / var(--speed)) ease-in-out infinite alternate;
}
.mist span:nth-child(1) {
  left: 25%;
  top: 46%;
  width: 60%;
  height: 22%;
}
.mist span:nth-child(2) {
  left: -15%;
  top: 50%;
  width: 55%;
  height: 18%;
  animation-duration: calc(34s / var(--speed));
  animation-delay: calc(-12s / var(--speed));
}
.mist span:nth-child(3) {
  left: 45%;
  top: 52%;
  width: 40%;
  height: 16%;
  animation-duration: calc(30s / var(--speed));
  animation-delay: calc(-6s / var(--speed));
}
@keyframes drift {
  from {
    transform: translateX(0);
  }
  to {
    transform: translateX(14%);
  }
}

/* The moving parts of the painting. Origins are points of the 2000 × 1500 drawing. */
.hair {
  transform-origin: 1336px 1100px;
  animation: hair calc(1.8s / var(--speed)) ease-in-out infinite alternate;
}
@keyframes hair {
  from {
    transform: rotate(0deg) scale(1);
  }
  to {
    transform: rotate(-4deg) scale(1.05, 0.95);
  }
}
.hoodFlap {
  transform-origin: 1340px 1132px;
  animation: flap calc(2.4s / var(--speed)) ease-in-out infinite alternate;
}
@keyframes flap {
  to {
    transform: rotate(5deg);
  }
}
.lantern {
  transform-origin: 1228px 1220px;
  animation: swing calc(3.6s / var(--speed)) ease-in-out infinite alternate;
}
@keyframes swing {
  from {
    transform: rotate(-1.5deg);
  }
  to {
    transform: rotate(2.5deg);
  }
}
.lampGlow {
  transform-origin: 1226px 1288px;
  animation: flicker calc(2.2s / var(--speed)) linear infinite;
}
.pool {
  transform-origin: 1240px 1360px;
  animation: flicker calc(2.2s / var(--speed)) linear infinite;
}
.lampGlass {
  animation: glass calc(2.2s / var(--speed)) linear infinite;
}
@keyframes flicker {
  0%,
  100% {
    opacity: 1;
    transform: scale(1);
  }
  12% {
    opacity: 0.82;
    transform: scale(0.96);
  }
  20% {
    opacity: 0.97;
    transform: scale(1.02);
  }
  38% {
    opacity: 0.86;
    transform: scale(0.97);
  }
  52% {
    opacity: 1;
    transform: scale(1.04);
  }
  64% {
    opacity: 0.78;
    transform: scale(0.95);
  }
  80% {
    opacity: 0.95;
    transform: scale(1.01);
  }
}
@keyframes glass {
  0%,
  100% {
    opacity: 1;
  }
  12%,
  64% {
    opacity: 0.86;
  }
  52% {
    opacity: 1;
  }
}
.foxTail {
  transform-origin: 1592px 1406px;
  animation: tail calc(5s / var(--speed)) ease-in-out infinite;
}
@keyframes tail {
  0%,
  78%,
  100% {
    transform: rotate(0deg);
  }
  84% {
    transform: rotate(-16deg);
  }
  89% {
    transform: rotate(6deg);
  }
  94% {
    transform: rotate(-9deg);
  }
}
.reeds {
  transform-origin: 910px 1336px;
  animation: reeds calc(4.4s / var(--speed)) ease-in-out infinite alternate;
}
@keyframes reeds {
  to {
    transform: rotate(1.6deg);
  }
}
.mote {
  animation: mote var(--length) ease-in-out var(--delay) infinite;
}
@keyframes mote {
  0%,
  100% {
    transform: translate(0, 0);
    opacity: 1;
  }
  50% {
    transform: translate(var(--dx), var(--dy));
    opacity: 0.35;
  }
}
/* Off screen, in a hidden tab, under reduced motion or paused: everything holds still. */
.root[data-playing='false'] * {
  animation-play-state: paused;
}

/* Play and pause. */
.toggle {
  position: absolute;
  left: 12px;
  bottom: 12px;
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--control);
  color: var(--control-ink);
  cursor: pointer;
  backdrop-filter: blur(6px);
  transition: background-color 160ms ease;
}
.toggle:hover {
  background: var(--control-hover);
}
.toggle:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.toggle svg {
  width: 14px;
  height: 14px;
  fill: currentColor;
}
```