# Bot avatars

> Plush, furry 3D faces for AI agents that look around, blink, hop at work and doze off, drawn on a plain canvas.

Avatars for the agents in an app: eighteen soft bodies, from a clover to a little robot, each with two painted eyes. They look around and follow the pointer, blink, and every few seconds hop, spin or flip; at work they bounce on a beat and beam as they land, and asleep they bow with their eyes shut. Five finishes, from fur to glossy plastic, hats, glasses and a dozen dials, all rendered by a small software renderer on a 2D canvas, with no WebGL.

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

Category: AI.

## Anatomy

1. **Avatar box**: The `size` it takes in the layout: a square that sits in a row like an icon. With `label` it is an image of that name; without it, decorative.
2. **Canvas**: Half as large again as the box, more of it above, so hops and hats have room; it overflows the box and ignores the pointer.
3. **Body**: The plush cushion inflated from the type’s outline, furred along its edge with `fabric` or `crisp` shading.
4. **Eyes**: Two ovals painted in `ink` on the face: they slide to look, flatten to blink and curve to beam or sleep.
5. **Glasses**: Round or square frames ringed around the eyes and bridged, sitting just off the face.
6. **Antenna**: A hard part some bodies carry (`droid`, `mech`, `alien`): a glossy stem and ball in a darker shade.
7. **Ear disc**: A flattened disc on each side of the `droid`’s head, in the same glossy finish as the antenna.

## When to use

- A face for each AI agent or assistant in an app: a list of bots, a chat reply, a task card, an empty state.
- Showing what an agent is doing at a glance: `idle` looks around, `working` hops on a beat while it thinks, `sleeping` dozes.
- Telling several bots apart: eighteen bodies, five finishes, hats, glasses and any colour, with a `seed` so neighbours don’t move in step.
- A friendly, tactile touch in a product that talks: it follows the pointer and leaps when poked.

## When not to use

- A person. Use a photo or an initials avatar; a plush creature reads as a bot.
- A precise status (queued, failed, 42 % done). Use a badge, a spinner or a progress bar beside it; the avatar’s mood is only a hint.
- Dense tables with hundreds of rows. Use a static icon or a single still frame: each avatar is a canvas, and many live ones cost frames.
- Serious or formal tools (finance, legal, health), where a playful mascot undercuts trust. Use a plain logo mark.
- Below about 24 px, where the fur and the eyes blur into a smudge. Use a coloured dot or an icon.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/bot-avatars.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 { BotAvatar } from './bot-avatar';

export function AgentRow({ busy }: { busy: boolean }) {
  return (
    <li>
      <BotAvatar type="flower" size={36} state={busy ? 'working' : 'idle'} label="Research aide" />
      Research aide
    </li>
  );
}
```

## States

- **Idle**: At rest it faces front, eyes open; live, it looks around, blinks and does a trick every few seconds.
- **Working**: `state="working"` hops on a beat and beams as it lands. Shown live: its still pose is idle’s.
- **Sleeping**: `state="sleeping"`: bowed, eyes shut in arcs, breathing slowly.
- **Sizes**: 96, 64, 36 and 32 px: the same drawing, the fur scaled with it.
- **Colour and ink**: Any `color` for the body and any `ink` for the eyes; the light and the tuning still apply.
- **Fur length**: `furLength` 0, 1 and 2, left to right: from a short velvet to a long shag.
- **Lit**: Strong `backLight`, a `sheen` and a deeper `shadow`, for a dark stage.

## How it works

- Each body is a flat outline written as a signed distance function. A grid laid over it is pulled onto the outline at the edge, then lifted into a cushion: the height is the square root of a membrane stretched over the outline and pushed out from behind (a Poisson problem solved once by over-relaxation), so it rounds off steeply at the seam and has no folds where lobes meet. The back is its mirror; the two sheets share the outline.
- Every frame the two sheets are squashed, turned and lifted into place and filled triangle by triangle into a depth buffer, which keeps each pixel’s normal and its spot on the face. Antennas, ear discs, hats and glasses are spheres, cones and tubes found by casting a ray through each pixel of their box into the same buffer.
- Each covered pixel is then lit once: a key light from the upper left with a soft wrap, the lit side washed paler, a back light round the rim and an optional sheen. The colour tuning (brightness and saturation) comes after the light, and saturates the shade more than the light, so shadows deepen instead of turning grey.
- The fur: a few thousand roots scattered evenly over the surface once, each with its own length, shade and lean. Those on the visible side grow along the turned normal and are drawn as fading lines over the lit body; along the outline they stand out against the background, which makes the soft fuzzy edge. Fine noise fixed to the surface breaks up the colour and turns with it. Plastic, smooth and flat skip the fur and are drawn at twice the size, then scaled down, for clean edges.
- The eyes are painted from the face’s own coordinates, so they turn with the body; they slide across the face to look, flatten to a dash to blink, and become arcs to beam (^ ^) or sleep (‿ ‿).
- Behaviour is a spring for the gaze and the eyes plus short timelines for tricks: a crouch, a parabolic hop that stretches on the way up and down, a full turn or flip in the air, and a landing squash that rings out. Squash and stretch keep the volume. Idle avatars look around and do a trick every few seconds; working ones hop on a beat; sleeping ones breathe slowly with a bow. Poking one makes it leap and land beaming.
- All avatars on a page share one animation frame loop, and each draws only while on screen in a visible tab. The pointer is read once per move for all of them; each looks at it while it moves, then goes back to looking around.

## API reference

- **type** (`'clover' | 'flower' | 'star' | 'ghost' | 'mech' | 'circle' | 'hexagon' | 'square' | 'triangle' | 'blob' | 'drop' | 'droid' | 'alien' | 'cat' | 'cloud' | 'pill' | 'pebble' | 'puddle'`, default `'clover'`): The body. The props down to label are the avatar’s (BotAvatar); the last three are the showcase’s (BotAvatars).
- **size** (`number`, default `64`): The box it takes in the layout, in px. The drawing overflows it, a quarter at the sides and more above, so hops and hats have room.
- **state** (`'idle' | 'working' | 'sleeping'`, default `'idle'`): Idle looks around and does a trick now and then; working hops on a beat; sleeping bows with its eyes shut and breathes slowly.
- **shading** (`'fabric' | 'plastic' | 'crisp' | 'smooth' | 'flat'`, default `'fabric'`): The finish: fur, glossy plastic, a short crisp pile, smooth velvet, or flat colour.
- **hat** (`'none' | 'beret' | 'beanie' | 'party' | 'crown'`, default `'none'`): Something to wear on top.
- **glasses** (`'none' | 'round' | 'square'`, default `'none'`): Frames around the eyes.
- **color** (`string`): The body’s colour, as hex, before light and tuning (the tuning deepens it); each type has its own without it.
- **ink** (`string`, default `'#1f1b2e'`): The eyes’ colour, used as it is.
- **furLength** (`number`, default `1`): Strand length, 0 to 2.
- **brightness** (`number`, default `120`): Brightness of the colour, in %.
- **saturation** (`number`, default `159`): Saturation of the colour, in %.
- **shadow** (`number`, default `115`): How deep the shading goes, in %.
- **backLight** (`number`, default `60`): Light round the rim from behind, 0 to 100 %.
- **sheen** (`number`, default `0`): A soft highlight from the key light, 0 to 100 %.
- **depth** (`number`, default `65`): Thickness of the body, 0 to 100 %.
- **edgeFuzz** (`number`, default `90`): How far the fur fluffs out along the outline, 0 to 100 %.
- **speed** (`number`, default `1`): Speed of every motion, as a multiple.
- **paused** (`boolean`, default `false`): Holds it still, facing front, in its state’s resting pose.
- **interactive** (`boolean`, default `true`): Looks at the pointer while it moves, and leaps when poked (clicked or tapped).
- **seed** (`number`): Varies its habits, so avatars side by side don’t move in step.
- **label** (`string`): A name for screen readers (role img); without it the avatar is decorative and hidden from them.
- **autoplay** (`boolean`, default `true`): Showcase: its avatars live on their own and the chat plays while on screen; false holds them still.
- **colorScheme** (`'light' | 'dark' | 'system'`): Showcase: its colours, light, dark, or the reader’s system setting. Without it the showcase follows the page’s color-scheme, which is light unless the page sets one. The avatars look the same on both.
- **className** (`string`): Extra class for the root.

## Accessibility

- An avatar is decorative (aria-hidden) unless it has a label, which makes it an image with that name. Next to a name, as in a list of bots, leave it decorative.
- The list says each bot’s state in words for screen readers (Idle, Working, Sleeping), not only by how the avatar moves.
- The chat’s reply is a polite live region, busy while the bot thinks and streams, so it is read once when complete.
- The playground’s options are radio groups (arrow keys, Home and End move the choice), the dials are native range inputs with their values spoken, the swatches are toggle buttons with names, and Play is a toggle button. Every control has a visible focus ring.
- Under reduced motion the avatars hold their resting pose (and still change with their state and options), the reply appears at once and Thinking… does not shimmer. They also stop off screen and in hidden tabs.

## Source

### bot-avatars/bot-avatars.tsx

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

'use client';

import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react';
import { BotAvatar, type BotAvatarProps } from './bot-avatar';
import type { State } from './brain';
import type { Glasses, Hat, Shading } from './render';
import { SHAPE_NAMES, type ShapeName } from './shapes';
import { useAutoplay } from './use-autoplay';
import styles from './bot-avatars.module.css';

/*
 * Bot avatars: plush, furry 3D faces for AI agents, drawn on a plain 2D canvas (no WebGL). This
 * file is the showcase around the avatar itself (bot-avatar.tsx):
 *
 * - A row of four, living on their own: looking around, blinking, hopping and spinning now and
 *   then, and following the pointer while it moves. Poke one and it leaps.
 * - Two scenes from an agent app: a list of bots, one idle, one at work, one asleep; and a chat,
 *   where the bot works while it thinks, then settles as its answer streams in word by word.
 * - A playground with every option (eighteen bodies, three states, three sizes, five finishes,
 *   hats, glasses, fur, colour, ink and light), and the code for what it shows.
 *
 * Colours are light-dark() tokens on the root, so the showcase follows the page's color-scheme or
 * the colorScheme prop; the avatars look the same on both.
 */

const TITLE = (s: string) => s[0].toUpperCase() + s.slice(1);

const STATES: State[] = ['idle', 'working', 'sleeping'];
const SIZES = [96, 64, 32];
const SHADINGS: Shading[] = ['fabric', 'plastic', 'crisp', 'smooth', 'flat'];
const HATS: Hat[] = ['none', 'beret', 'beanie', 'party', 'crown'];
const GLASSES: Glasses[] = ['none', 'round', 'square'];
/**
 * Body colours: the swatch shows the colour as it reads on the lit face; the value is the colour
 * before light and tuning, which is what the avatar takes.
 */
const COLORS = [
  { name: 'Sky', swatch: '#41c4ff', value: '#68badf' },
  { name: 'Mint', swatch: '#2fcb7a', value: '#5abb88' },
  { name: 'Orchid', swatch: '#dc48ff', value: '#b458ca' },
  { name: 'Ocean', swatch: '#35b8ff', value: '#5caeda' },
  { name: 'Snow', swatch: '#f4f2fa', value: '#f2f0f5' },
];
/** Eye colours, used as they are. */
const INKS = [
  { name: 'Night', swatch: '#1f1b2e', value: '#1f1b2e' },
  { name: 'Chalk', swatch: '#f6f4f0', value: '#f6f4f0' },
  { name: 'Ocean', swatch: '#35b8ff', value: '#35b8ff' },
  { name: 'Orchid', swatch: '#dc48ff', value: '#dc48ff' },
];

const REPLY =
  'Three things stood out: the pilot starts in May, legal wants the data terms by Friday, and the budget stays where it was.';
const QUESTION = 'Could you sum up the notes from Tuesday’s call?';

type Settings = {
  type: ShapeName;
  state: State;
  size: number;
  shading: Shading;
  hat: Hat;
  glasses: Glasses;
  furLength: number;
  color: string | undefined;
  brightness: number;
  saturation: number;
  ink: string;
  shadow: number;
  backLight: number;
  sheen: number;
  depth: number;
  edgeFuzz: number;
  speed: number;
};

const DEFAULTS: Settings = {
  type: 'clover',
  state: 'idle',
  size: 96,
  shading: 'fabric',
  hat: 'none',
  glasses: 'none',
  furLength: 100,
  color: undefined,
  brightness: 120,
  saturation: 159,
  ink: '#1f1b2e',
  shadow: 115,
  backLight: 60,
  sheen: 0,
  depth: 65,
  edgeFuzz: 90,
  speed: 1,
};

export function BotAvatars({
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The showcase's avatars live on their own and the chat plays while on screen; false holds them still. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const still = !autoplay;
  return (
    <div className={`${styles.root} ${className}`} data-color-scheme={colorScheme}>
      <section className={styles.preview} aria-label="Avatars" data-part="preview">
        <div className={styles.team}>
          {(
            [
              ['clover', undefined],
              ['flower', undefined],
              ['ghost', undefined],
              ['square', '#b15da8'],
            ] as [ShapeName, string | undefined][]
          ).map(([type, color], i) => (
            <figure key={type} className={styles.seat}>
              <BotAvatar type={type} color={color} size={96} seed={i + 3} paused={still} />
              <figcaption className={styles.caption}>{TITLE(type)}</figcaption>
            </figure>
          ))}
        </div>
      </section>

      <div className={styles.split}>
        <section className={styles.cell} aria-label="Bots list" data-part="list">
          <BotsList still={still} />
        </section>
        <section className={styles.cell} aria-label="Chat" data-part="chat">
          <Chat still={still} />
        </section>
      </div>

      <Playground />
    </div>
  );
}

function BotsList({ still }: { still: boolean }) {
  const bots: {
    name: string;
    status: string;
    type: ShapeName;
    state: State;
  }[] = [
    {
      name: 'Mail sorter',
      status: '12 threads triaged, 4 replies queued',
      type: 'star',
      state: 'idle',
    },
    {
      name: 'Research aide',
      status: 'Reading 3 reports on churn',
      type: 'flower',
      state: 'working',
    },
    { name: 'Night watch', status: 'Asleep until 8:30', type: 'ghost', state: 'sleeping' },
  ];
  return (
    <div className={styles.bots}>
      <h3 className={styles.botsHead}>Bots</h3>
      <ul className={styles.botsList}>
        {bots.map((bot, i) => (
          <li key={bot.name} className={styles.botRow}>
            <BotAvatar type={bot.type} state={bot.state} size={36} seed={i + 11} paused={still} />
            <span className={styles.botText}>
              <span className={styles.botName}>{bot.name}</span>
              <span className={styles.botStatus}>
                <span className={styles.srOnly}>{TITLE(bot.state)}: </span>
                {bot.status}
              </span>
            </span>
          </li>
        ))}
      </ul>
    </div>
  );
}

/** How long the bot thinks, then how long each word takes to come in, then how long it rests. */
const THINK = 2600;
const WORD = 70;
const HOLD = 1700;

function Chat({ still }: { still: boolean }) {
  const { ref, playing } = useAutoplay<HTMLDivElement>(6000);
  const words = REPLY.split(' ');
  // -1 while thinking, then how many words are in.
  const [shown, setShown] = useState(words.length);
  const run = playing && !still;

  useEffect(() => {
    if (!run) {
      setShown(words.length);
      return;
    }
    let timer = 0;
    let n = -1;
    const next = () => {
      if (n < 0) {
        setShown(-1);
        n = 0;
        timer = window.setTimeout(next, THINK);
      } else if (n < words.length) {
        n++;
        setShown(n);
        timer = window.setTimeout(next, n === words.length ? HOLD + words.length * 6 : WORD);
      } else {
        n = -1;
        next();
      }
    };
    next();
    return () => window.clearTimeout(timer);
  }, [run, words.length]);

  const thinking = shown < 0;
  return (
    <div ref={ref} className={styles.thread}>
      <p className={styles.bubble}>{QUESTION}</p>
      <div className={styles.botLine}>
        <BotAvatar
          type="flower"
          size={32}
          seed={21}
          state={thinking ? 'working' : 'idle'}
          paused={still}
        />
        <div className={styles.reply}>
          {/* The full answer keeps the space, so nothing below jumps as it streams. */}
          <p className={styles.replyGhost} aria-hidden="true">
            {REPLY}
          </p>
          <p
            className={styles.replyLive}
            aria-live="polite"
            aria-busy={thinking || shown < words.length}
          >
            {thinking ? (
              <span className={styles.thinking}>Thinking…</span>
            ) : (
              words.slice(0, shown).map((w, i) => (
                <span key={i} className={styles.word}>
                  {w}{' '}
                </span>
              ))
            )}
          </p>
        </div>
      </div>
    </div>
  );
}

function Playground() {
  const [s, setS] = useState<Settings>(DEFAULTS);
  const [playing, setPlaying] = useState(false);
  const set = <K extends keyof Settings>(key: K, value: Settings[K]) =>
    setS((now) => ({ ...now, [key]: value }));

  const props: BotAvatarProps = {
    type: s.type,
    state: s.state,
    size: s.size,
    shading: s.shading,
    hat: s.hat,
    glasses: s.glasses,
    furLength: s.furLength / 100,
    color: s.color,
    brightness: s.brightness,
    saturation: s.saturation,
    ink: s.ink,
    shadow: s.shadow,
    backLight: s.backLight,
    sheen: s.sheen,
    depth: s.depth,
    edgeFuzz: s.edgeFuzz,
    speed: s.speed,
  };

  return (
    <section
      className={styles.playground}
      aria-labelledby="bot-avatars-playground"
      data-part="playground"
    >
      <h3 id="bot-avatars-playground" className={styles.pgTitle}>
        Playground
      </h3>
      <div className={styles.pg}>
        <div className={styles.stage} data-part="stage">
          <BotAvatar
            {...props}
            paused={!playing}
            seed={5}
            label={`${TITLE(s.type)} avatar, ${s.state}`}
          />
          <button
            type="button"
            className={styles.play}
            aria-pressed={playing}
            onClick={() => setPlaying((p) => !p)}
          >
            {playing ? 'Pause' : 'Play'}
          </button>
        </div>
        <div className={styles.controls} data-part="controls">
          <Chips
            label="Type"
            options={SHAPE_NAMES}
            value={s.type}
            onChange={(v) => set('type', v)}
          />
          <Chips label="State" options={STATES} value={s.state} onChange={(v) => set('state', v)} />
          <Chips
            label="Size"
            options={SIZES}
            value={s.size}
            format={(v) => `${v}px`}
            onChange={(v) => set('size', v)}
          />
          <Chips
            label="Shading"
            options={SHADINGS}
            value={s.shading}
            onChange={(v) => set('shading', v)}
          />
          <Chips label="Hat" options={HATS} value={s.hat} onChange={(v) => set('hat', v)} />
          <Chips
            label="Glasses"
            options={GLASSES}
            value={s.glasses}
            onChange={(v) => set('glasses', v)}
          />
          <div className={styles.tuning}>
            <Slider
              label="Fur length"
              min={0}
              max={200}
              value={s.furLength}
              unit="%"
              onChange={(v) => set('furLength', v)}
            />
            <Swatches
              label="Color"
              options={COLORS}
              value={s.color}
              onChange={(v) => set('color', v)}
            />
            <Slider
              label="Brightness"
              min={40}
              max={200}
              value={s.brightness}
              unit="%"
              onChange={(v) => set('brightness', v)}
            />
            <Slider
              label="Saturation"
              min={0}
              max={250}
              value={s.saturation}
              unit="%"
              onChange={(v) => set('saturation', v)}
            />
            <Swatches
              label="Ink"
              options={INKS}
              value={s.ink}
              onChange={(v) => set('ink', v ?? DEFAULTS.ink)}
            />
            <Slider
              label="Shadow"
              min={0}
              max={200}
              value={s.shadow}
              unit="%"
              onChange={(v) => set('shadow', v)}
            />
            <Slider
              label="Back light"
              min={0}
              max={100}
              value={s.backLight}
              unit="%"
              onChange={(v) => set('backLight', v)}
            />
            <Slider
              label="Sheen"
              min={0}
              max={100}
              value={s.sheen}
              unit="%"
              onChange={(v) => set('sheen', v)}
            />
            <Slider
              label="Depth"
              min={0}
              max={100}
              value={s.depth}
              unit="%"
              onChange={(v) => set('depth', v)}
            />
            <Slider
              label="Edge fuzz"
              min={0}
              max={100}
              value={s.edgeFuzz}
              unit="%"
              onChange={(v) => set('edgeFuzz', v)}
            />
            <Slider
              label="Speed"
              min={0.25}
              max={3}
              step={0.25}
              value={s.speed}
              unit="×"
              onChange={(v) => set('speed', v)}
            />
          </div>
        </div>
      </div>
      <Code settings={s} />
    </section>
  );
}

/** A row of chips: one radio group, arrow keys move the choice. */
function Chips<T extends string | number>({
  label,
  options,
  value,
  onChange,
  format = (v) => TITLE(String(v)),
}: {
  label: string;
  options: readonly T[];
  value: T;
  onChange: (value: T) => void;
  format?: (value: T) => string;
}) {
  const id = useId();
  const group = useRef<HTMLDivElement>(null);
  const onKey = (e: KeyboardEvent) => {
    const step =
      e.key === 'ArrowRight' || e.key === 'ArrowDown'
        ? 1
        : e.key === 'ArrowLeft' || e.key === 'ArrowUp'
          ? -1
          : 0;
    const jump = e.key === 'Home' ? 'first' : e.key === 'End' ? 'last' : null;
    if (!step && !jump) return;
    e.preventDefault();
    const at = options.indexOf(value);
    const next =
      jump === 'first'
        ? 0
        : jump === 'last'
          ? options.length - 1
          : (at + step + options.length) % options.length;
    onChange(options[next]);
    const buttons = group.current?.querySelectorAll<HTMLButtonElement>('button');
    buttons?.[next]?.focus();
  };
  return (
    <div className={styles.field}>
      <span id={id} className={styles.fieldLabel}>
        {label}
      </span>
      <div
        ref={group}
        role="radiogroup"
        aria-labelledby={id}
        className={styles.chips}
        onKeyDown={onKey}
      >
        {options.map((o) => (
          <button
            key={o}
            type="button"
            role="radio"
            aria-checked={o === value}
            tabIndex={o === value ? 0 : -1}
            className={styles.chip}
            onClick={() => onChange(o)}
          >
            {format(o)}
          </button>
        ))}
      </div>
    </div>
  );
}

function Slider({
  label,
  min,
  max,
  step = 1,
  value,
  unit,
  onChange,
}: {
  label: string;
  min: number;
  max: number;
  step?: number;
  value: number;
  unit: string;
  onChange: (value: number) => void;
}) {
  const shown = unit === '×' ? `${value}×` : `${value}%`;
  const fill = ((value - min) / (max - min)) * 100;
  return (
    <div className={styles.field}>
      <span className={styles.fieldLabel} aria-hidden="true">
        {label}
      </span>
      <label className={styles.slider}>
        <span className={styles.sliderFill} style={{ width: `${fill}%` }} aria-hidden="true" />
        <span className={styles.sliderName} aria-hidden="true">
          {label}
        </span>
        <span className={styles.sliderValue} aria-hidden="true">
          {shown}
        </span>
        <input
          type="range"
          className={styles.range}
          min={min}
          max={max}
          step={step}
          value={value}
          aria-label={label}
          aria-valuetext={shown}
          onChange={(e) => onChange(Number(e.target.value))}
        />
      </label>
    </div>
  );
}

function Swatches({
  label,
  options,
  value,
  onChange,
}: {
  label: string;
  options: { name: string; swatch: string; value: string }[];
  value: string | undefined;
  onChange: (value: string | undefined) => void;
}) {
  const id = useId();
  return (
    <div className={styles.field}>
      <span id={id} className={styles.fieldLabel}>
        {label}
      </span>
      <div role="group" aria-labelledby={id} className={styles.swatches}>
        {options.map((o) => (
          <button
            key={o.value}
            type="button"
            className={styles.swatch}
            style={{ background: o.swatch }}
            aria-label={o.name}
            aria-pressed={o.value === value}
            onClick={() => onChange(o.value === value && label === 'Color' ? undefined : o.value)}
          />
        ))}
      </div>
    </div>
  );
}

function Code({ settings: s }: { settings: Settings }) {
  const [copied, setCopied] = useState(false);
  const attrs: string[] = [`type="${s.type}"`, `size={${s.size}}`];
  const keys: (keyof Settings)[] = [
    'state',
    'shading',
    'hat',
    'glasses',
    'color',
    'ink',
    'brightness',
    'saturation',
    'shadow',
    'backLight',
    'sheen',
    'depth',
    'edgeFuzz',
    'speed',
  ];
  for (const k of keys) {
    const v = s[k];
    if (v === DEFAULTS[k] || v === undefined) continue;
    attrs.push(typeof v === 'string' ? `${k}="${v}"` : `${k}={${v}}`);
  }
  if (s.furLength !== DEFAULTS.furLength) attrs.push(`furLength={${s.furLength / 100}}`);
  const code = `import { BotAvatar } from './bot-avatar';\n\n<BotAvatar ${attrs.join(' ')} />`;
  useEffect(() => {
    if (!copied) return;
    const t = window.setTimeout(() => setCopied(false), 1600);
    return () => window.clearTimeout(t);
  }, [copied]);
  return (
    <div className={styles.code} data-part="code">
      <pre className={styles.pre}>
        <code>{code}</code>
      </pre>
      <button
        type="button"
        className={styles.copy}
        aria-label={copied ? 'Copied' : 'Copy code'}
        onClick={() => {
          navigator.clipboard?.writeText(code).then(
            () => setCopied(true),
            () => {},
          );
        }}
      >
        <svg viewBox="0 0 16 16" className={styles.copyIcon} aria-hidden="true">
          {copied ? (
            <path
              d="M3.5 8.5l3 3 6-7"
              fill="none"
              stroke="currentColor"
              strokeWidth="1.5"
              strokeLinecap="round"
              strokeLinejoin="round"
            />
          ) : (
            <>
              <rect
                x="5.5"
                y="5.5"
                width="8"
                height="8"
                rx="2"
                fill="none"
                stroke="currentColor"
                strokeWidth="1.3"
              />
              <path
                d="M10.5 3.5v-.5a1.5 1.5 0 0 0-1.5-1.5H4A1.5 1.5 0 0 0 2.5 3v5A1.5 1.5 0 0 0 4 9.5h.5"
                fill="none"
                stroke="currentColor"
                strokeWidth="1.3"
              />
            </>
          )}
        </svg>
        <span className={styles.tooltip} aria-hidden="true">
          {copied ? 'Copied' : 'Copy code'}
        </span>
      </button>
      <span className={styles.srOnly} aria-live="polite">
        {copied ? 'Code copied' : ''}
      </span>
    </div>
  );
}
```

### bot-avatars/bot-avatar.tsx

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

'use client';

import { useEffect, useRef, type CSSProperties } from 'react';
import { Brain, type State } from './brain';
import { Renderer, type Glasses, type Hat, type Look, type Shading } from './render';
import type { ShapeName } from './shapes';
import styles from './bot-avatars.module.css';

/*
 * One avatar: a canvas half as large again as the avatar's box (more of it above, so a hop or a hat
 * has room), with the body drawn in the box by the renderer (render.ts) in the pose its brain (brain.ts) gives
 * it each frame.
 *
 * All avatars on a page share one animation frame loop, and each only draws while it is on
 * screen, the tab is visible and it isn't paused. Under reduced motion it draws its resting pose
 * once (and again when a prop changes). The pointer is read once per move for all of them; each
 * looks at it while it moves, then goes back to looking around on its own.
 */

export type BotAvatarProps = {
  /** The body. */
  type?: ShapeName;
  /** The box it takes in the layout, in px; the drawing overflows it, most of all above. */
  size?: number;
  /** Idle looks around and does tricks; working hops on a beat; sleeping dozes, eyes shut. */
  state?: State;
  /** The finish: furry fabric, glossy plastic, short crisp pile, smooth velvet or flat colour. */
  shading?: Shading;
  hat?: Hat;
  glasses?: Glasses;
  /** The body's colour (any hex); each type has its own without it. */
  color?: string;
  /** The eyes' colour. */
  ink?: string;
  /** Strand length, 0–2 (1 is the default length). */
  furLength?: number;
  /** Brightness and saturation of the colour, in % (120 and 159 are neutral). */
  brightness?: number;
  saturation?: number;
  /** Depth of the shading, in % (115 is the default). */
  shadow?: number;
  /** Light round the rim from behind, 0–100 %. */
  backLight?: number;
  /** A soft highlight from the key light, 0–100 %. */
  sheen?: number;
  /** Thickness of the body, 0–100 % (65 is the default). */
  depth?: number;
  /** How far the fur fluffs out along the outline, 0–100 %. */
  edgeFuzz?: number;
  /** Speed of every motion, as a multiple (1 is the default). */
  speed?: number;
  /** Holds the avatar still, facing front. */
  paused?: boolean;
  /** Looks at the pointer, and leaps when poked (clicked or tapped). */
  interactive?: boolean;
  /** Varies its habits, so avatars side by side don't move in step. */
  seed?: number;
  /** A name for screen readers; without it the avatar is decorative. */
  label?: string;
  className?: string;
  style?: CSSProperties;
};

type Instance = {
  canvas: HTMLCanvasElement;
  renderer: Renderer;
  brain: Brain;
  props: Required<Omit<BotAvatarProps, 'label' | 'className' | 'style' | 'color'>> & {
    color?: string;
  };
  visible: boolean;
  dirty: boolean;
  last: number;
};

const instances = new Set<Instance>();
let frame = 0;
let reduced = false;
const pointer = { x: 0, y: 0, at: -1e9 };
let listening = false;

function listen() {
  if (listening || typeof window === 'undefined') return;
  listening = true;
  window.addEventListener(
    'pointermove',
    (e) => {
      if (e.pointerType === 'touch') return;
      pointer.x = e.clientX;
      pointer.y = e.clientY;
      pointer.at = performance.now();
    },
    { passive: true },
  );
  const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
  reduced = motion.matches;
  motion.addEventListener('change', () => {
    reduced = motion.matches;
    for (const i of instances) i.dirty = true;
    schedule();
  });
  document.addEventListener('visibilitychange', () => {
    for (const i of instances) i.last = 0;
    schedule();
  });
}

function schedule() {
  if (!frame && typeof window !== 'undefined') frame = requestAnimationFrame(tick);
}

function tick(now: number) {
  frame = 0;
  if (document.hidden) return;
  let again = false;
  const pointerLive = now - pointer.at < 2500;
  // With many on screen, each draws every other frame (30 a second), which halves the work.
  let shown = 0;
  for (const inst of instances) if (inst.visible) shown++;
  const every = shown > 3 ? 1000 / 30 - 4 : 0;
  for (const inst of instances) {
    if (!inst.visible) continue;
    const p = inst.props;
    const still = p.paused || reduced;
    if (still && !inst.dirty) continue;
    if (!still && !inst.dirty && inst.last && now - inst.last < every) {
      again = true;
      continue;
    }
    const dt = inst.last ? (now - inst.last) / 1000 : 1 / 60;
    inst.last = now;
    let look: { x: number; y: number } | null = null;
    if (pointerLive && p.interactive && !still) {
      const r = inst.canvas.getBoundingClientRect();
      const dx = pointer.x - (r.left + r.width / 2);
      const dy = pointer.y - (r.top + r.height / 2);
      const reach = 260;
      look = {
        x: Math.max(-1, Math.min(1, dx / reach)),
        y: Math.max(-1, Math.min(1, -dy / reach)),
      };
    }
    const pose = inst.brain.step(dt * p.speed, p.state, look, still);
    const dpr = Math.min(2, window.devicePixelRatio || 1);
    inst.renderer.draw(toLook(p), pose, p.size * dpr);
    inst.dirty = false;
    if (!still) again = true;
  }
  if (again) schedule();
}

function toLook(p: Instance['props']): Look {
  return {
    type: p.type,
    shading: p.shading,
    hat: p.hat,
    glasses: p.glasses,
    color: p.color,
    ink: p.ink,
    furLength: p.furLength,
    brightness: p.brightness / 120,
    saturation: p.saturation / 159,
    shadow: p.shadow / 115,
    backLight: p.backLight / 100,
    sheen: p.sheen / 100,
    depth: 0.4 + (p.depth / 100) * 0.92,
    edgeFuzz: p.edgeFuzz / 100,
  };
}

let observer: IntersectionObserver | null = null;
const byCanvas = new WeakMap<Element, Instance>();
function observe(inst: Instance) {
  if (!observer) {
    observer = new IntersectionObserver(
      (entries) => {
        for (const e of entries) {
          const i = byCanvas.get(e.target);
          if (!i) continue;
          i.visible = e.isIntersecting;
          i.dirty = true;
          i.last = 0;
        }
        schedule();
      },
      { rootMargin: '40px' },
    );
  }
  byCanvas.set(inst.canvas, inst);
  observer.observe(inst.canvas);
}

let seeds = 1;

export function BotAvatar({
  type = 'clover',
  size = 64,
  state = 'idle',
  shading = 'fabric',
  hat = 'none',
  glasses = 'none',
  color,
  ink = '#1f1b2e',
  furLength = 1,
  brightness = 120,
  saturation = 159,
  shadow = 115,
  backLight = 60,
  sheen = 0,
  depth = 65,
  edgeFuzz = 90,
  speed = 1,
  paused = false,
  interactive = true,
  seed,
  label,
  className = '',
  style,
}: BotAvatarProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const inst = useRef<Instance | null>(null);
  const props = {
    type,
    size,
    state,
    shading,
    hat,
    glasses,
    color,
    ink,
    furLength,
    brightness,
    saturation,
    shadow,
    backLight,
    sheen,
    depth,
    edgeFuzz,
    speed,
    paused,
    interactive,
    seed: seed ?? 0,
  };
  const key = JSON.stringify(props);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext('2d');
    if (!ctx) return;
    listen();
    const i: Instance = {
      canvas,
      renderer: new Renderer(ctx),
      brain: new Brain(seed ?? seeds++),
      props: JSON.parse(key),
      visible: false,
      dirty: true,
      last: 0,
    };
    inst.current = i;
    instances.add(i);
    observe(i);
    return () => {
      instances.delete(i);
      observer?.unobserve(canvas);
      inst.current = null;
    };
    // The brain lives as long as the canvas; props flow in below.
  }, []);

  useEffect(() => {
    const i = inst.current;
    const canvas = canvasRef.current;
    if (!i || !canvas) return;
    i.props = JSON.parse(key);
    const dpr = Math.min(2, window.devicePixelRatio || 1);
    const px = Math.round(i.props.size * 1.5 * dpr);
    if (canvas.width !== px) {
      canvas.width = px;
      canvas.height = px;
    }
    i.dirty = true;
    i.last = 0;
    schedule();
  }, [key]);

  const poke = () => {
    if (!interactive || paused || reduced) return;
    inst.current?.brain.poke();
    schedule();
  };

  return (
    <span
      className={`${styles.avatar} ${className}`}
      style={{ ...style, ['--size' as string]: `${size}px` }}
      role={label ? 'img' : undefined}
      aria-label={label}
      aria-hidden={label ? undefined : true}
      data-part="avatar"
      onPointerDown={poke}
    >
      <canvas ref={canvasRef} className={styles.canvas} aria-hidden="true" />
    </span>
  );
}
```

### bot-avatars/brain.ts

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

import type { Pose } from './render';

/*
 * What an avatar does, frame by frame: it breathes, looks around (or at the pointer when one
 * moves nearby), blinks now and then, and from time to time does a trick: a hop, a hop with a
 * full turn, a flip, a wiggle. At work it hops on a beat, now and then spinning and squinting
 * happily as it lands. Asleep it bows a little, eyes shut in two arcs, breathing slowly.
 * Poking it makes it crouch, leap with a turn and land beaming.
 *
 * Every motion is either a spring (the gaze, the eyes) or a short timeline (a trick), added
 * together into one pose; a trick's squash and stretch keep the body's volume about the same.
 */

export type State = 'idle' | 'working' | 'sleeping';

type Trick = {
  kind: 'hop' | 'spin' | 'flip' | 'wiggle' | 'squash' | 'poke';
  start: number;
  height: number;
  turns: number;
  roll: number;
  happy: boolean;
};

/** A critically damped spring towards a target, `rate` per second. */
function follow(value: number, target: number, velocity: number, rate: number, dt: number) {
  const w = rate;
  const x = value - target;
  const e = Math.exp(-w * dt);
  const nx = (x + (velocity + w * x) * dt) * e;
  const nv = (velocity - w * (velocity + w * x) * dt) * e;
  return [target + nx, nv] as const;
}

const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);

/** How long each part of a trick takes, in seconds. */
const CROUCH = 0.13;
const AIR = 0.42;
const SETTLE = 0.7;

export class Brain {
  t = 0;
  private rand: () => number;
  private gaze = { x: 0, y: 0, vx: 0, vy: 0, tx: 0, ty: 0, next: 0 };
  private blink = { next: 2, at: -10, twice: false };
  private eyes = { open: 1, happy: 0, sleep: 0, vo: 0, vh: 0, vs: 0 };
  private trick: Trick | null = null;
  private nextTrick = 3;
  private hops = 0;
  private bow = { v: 0, vel: 0 };
  private happyUntil = 0;
  private lastState: State | null = null;

  constructor(seed: number) {
    let s = (seed * 2654435761) >>> 0 || 1;
    this.rand = () => {
      s ^= s << 13;
      s ^= s >>> 17;
      s ^= s << 5;
      return (s >>> 0) / 4294967296;
    };
    this.t = this.rand() * 10;
    this.nextTrick = this.t + 0.8 + this.rand() * 3;
    this.blink.next = this.t + 0.8 + this.rand() * 3;
    this.gaze.next = this.t;
  }

  /** Starts the poke: a crouch, a leap with a full turn, a beaming landing. */
  poke() {
    this.trick = {
      kind: 'poke',
      start: this.t,
      height: 0.66,
      turns: 1,
      roll: 0,
      happy: true,
    };
  }

  /**
   * Moves `dt` seconds on. `pointer`, when set, is where the reader's pointer is, as -1 to 1 on
   * each axis around the avatar (y up). With `still`, nothing moves: the pose is the state's
   * resting one.
   */
  step(dt: number, state: State, pointer: { x: number; y: number } | null, still: boolean): Pose {
    if (still) return this.rest(state);
    dt = Math.min(dt, 0.1);
    this.t += dt;
    const t = this.t;
    const r = this.rand;
    const asleep = state === 'sleeping';
    // A new state starts at once: work begins with a hop, waking up with a look around.
    if (state !== this.lastState) {
      if (this.lastState !== null) {
        this.nextTrick = t + (state === 'working' ? 0.15 : 1 + r() * 1.5);
        this.gaze.next = t;
      }
      this.lastState = state;
    }

    // Where to look.
    const g = this.gaze;
    if (asleep) {
      g.tx = 0;
      g.ty = -0.25;
    } else if (pointer) {
      g.tx = pointer.x;
      g.ty = pointer.y;
      g.next = t + 0.8;
    } else if (t >= g.next) {
      const roll = r();
      if (roll < 0.3) {
        g.tx = 0;
        g.ty = 0;
      } else {
        g.tx = (r() * 2 - 1) * 0.95;
        g.ty = (r() * 2 - 1) * 0.6;
      }
      g.next = t + (state === 'working' ? 0.6 + r() * 1 : 0.9 + r() * 1.8);
    }
    const rate = pointer ? 9 : 5.5;
    [g.x, g.vx] = follow(g.x, g.tx, g.vx, rate, dt);
    [g.y, g.vy] = follow(g.y, g.ty, g.vy, rate, dt);

    // Blinks.
    const b = this.blink;
    if (!asleep && t >= b.next) {
      b.at = t;
      b.twice = r() < 0.18;
      b.next = t + 2.2 + r() * 3.6;
    }
    const since = t - b.at;
    let blinkClose = 0;
    const one = (s: number) =>
      s < 0 ? 0 : s < 0.06 ? s / 0.06 : s < 0.16 ? 1 - (s - 0.06) / 0.1 : 0;
    blinkClose = Math.max(one(since), b.twice ? one(since - 0.22) : 0);

    // Tricks.
    if (!this.trick && !asleep && t >= this.nextTrick) {
      this.trick = this.pickTrick(state);
    }
    if (asleep && this.trick && this.trick.kind !== 'poke') this.trick = null;
    let y = 0;
    let sx = 1;
    let sy = 1;
    let yaw = 0;
    let roll = 0;
    let happy = false;
    const k = this.trick;
    if (k) {
      const s = t - k.start;
      const total = k.kind === 'wiggle' ? 0.9 : k.kind === 'squash' ? 0.8 : CROUCH + AIR + SETTLE;
      if (s >= total) {
        if (k.happy) this.happyUntil = t + 0.45;
        this.trick = null;
        this.nextTrick = t + (state === 'working' ? 0.15 + r() * 0.45 : 1.4 + r() * 3.4);
      } else if (k.kind === 'wiggle') {
        yaw = Math.sin(s * 22) * 0.32 * (1 - s / total);
      } else if (k.kind === 'squash') {
        const a = Math.sin((s / total) * Math.PI);
        const wob = Math.exp(-s * 5) * Math.sin(s * 18);
        sy = 1 - 0.18 * a + 0.05 * wob;
        sx = 1 / Math.sqrt(sy);
      } else {
        happy = k.happy && s > CROUCH * 0.5;
        if (s < CROUCH) {
          const c = Math.sin((s / CROUCH) * (Math.PI / 2));
          sy = 1 - (k.kind === 'poke' ? 0.26 : 0.17) * c;
          sx = 1 / Math.sqrt(sy);
        } else if (s < CROUCH + AIR) {
          const a = (s - CROUCH) / AIR;
          y = k.height * 4 * a * (1 - a);
          const stretch = Math.abs(1 - 2 * a);
          sy = 1 + 0.13 * stretch ** 1.4;
          sx = 1 / Math.sqrt(sy);
          yaw = k.turns * Math.PI * 2 * easeInOut(a);
          roll = k.roll * Math.PI * 2 * easeInOut(a);
        } else {
          const l = s - CROUCH - AIR;
          const amp = (k.turns || k.roll ? 0.32 : 0.24) * (k.height / 0.4);
          const sq = Math.min(0.42, amp) * Math.exp(-l * 7.5) * Math.cos(l * 19);
          sy = 1 - sq;
          sx = 1 / Math.sqrt(sy);
        }
      }
    }
    if (t < this.happyUntil) happy = true;

    // Eyes: open, happy arcs, or asleep.
    const e = this.eyes;
    const openTarget = asleep ? 0 : 1 - blinkClose;
    if (blinkClose > 0) {
      e.open = openTarget;
      e.vo = 0;
    } else [e.open, e.vo] = follow(e.open, openTarget, e.vo, asleep ? 6 : 18, dt);
    [e.happy, e.vh] = follow(e.happy, happy && !asleep ? 1 : 0, e.vh, 22, dt);
    [e.sleep, e.vs] = follow(e.sleep, asleep ? 1 : 0, e.vs, 5, dt);

    // Breathing, and the sleeper's bow.
    const period = asleep ? 4.2 : 2.9;
    const breath = Math.sin((t / period) * Math.PI * 2);
    const amp = asleep ? 0.028 : 0.012;
    sy *= 1 + breath * amp;
    sx *= 1 - breath * amp * 0.6;
    [this.bow.v, this.bow.vel] = follow(this.bow.v, asleep ? 1 : 0, this.bow.vel, 3, dt);
    const bow = this.bow.v;

    return {
      y,
      sx,
      sy,
      yaw: yaw + g.x * 0.85,
      pitch: -g.y * 0.45 + bow * (0.16 + breath * 0.03),
      roll: roll + bow * Math.sin(t * 0.6) * 0.05 - g.x * 0.04,
      lookX: g.x * 0.1,
      lookY: g.y * 0.07,
      open: e.open,
      happy: Math.min(1, Math.max(0, e.happy)),
      sleep: Math.min(1, Math.max(0, e.sleep)),
    };
  }

  private pickTrick(state: State): Trick {
    const r = this.rand();
    const t = this.t;
    if (state === 'working') {
      this.hops++;
      const spin = this.hops % 3 === 0;
      return {
        kind: spin ? 'spin' : 'hop',
        start: t,
        height: spin ? 0.5 : 0.32,
        turns: spin ? (this.rand() < 0.5 ? 1 : -1) : 0,
        roll: 0,
        happy: spin && this.rand() < 0.6,
      };
    }
    if (r < 0.3) return { kind: 'hop', start: t, height: 0.36, turns: 0, roll: 0, happy: false };
    if (r < 0.64)
      return {
        kind: 'spin',
        start: t,
        height: 0.52,
        turns: this.rand() < 0.5 ? 1 : -1,
        roll: 0,
        happy: false,
      };
    if (r < 0.72)
      return {
        kind: 'flip',
        start: t,
        height: 0.6,
        turns: 0,
        roll: this.rand() < 0.5 ? 1 : -1,
        happy: true,
      };
    if (r < 0.86) return { kind: 'wiggle', start: t, height: 0, turns: 0, roll: 0, happy: false };
    return { kind: 'squash', start: t, height: 0, turns: 0, roll: 0, happy: false };
  }

  /** The state's resting pose: facing front, eyes open (or shut, asleep). */
  rest(state: State): Pose {
    const asleep = state === 'sleeping';
    return {
      y: 0,
      sx: 1,
      sy: 1,
      yaw: 0,
      pitch: asleep ? 0.16 : 0,
      roll: 0,
      lookX: 0,
      lookY: asleep ? -0.02 : 0,
      open: asleep ? 0 : 1,
      happy: 0,
      sleep: asleep ? 1 : 0,
    };
  }
}
```

### bot-avatars/render.ts

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

import { getMesh, getRoots, ROOT_STRIDE, type Mesh } from './mesh';
import { SHAPES, type ShapeName } from './shapes';

/*
 * A small software renderer on a 2D canvas: no WebGL.
 *
 * 1. The body's two sheets are turned and squashed into place, and their triangles filled into a
 *    depth buffer pixel by pixel; each pixel keeps the nearest surface's normal and its spot on
 *    the face. Hats, glasses and antennas are solids (spheres, cones, tubes) found by casting a
 *    ray through each pixel of their box, into the same buffer.
 * 2. Every pixel the body covers is lit once: a soft key light from the upper left, a fill, a
 *    back light round the rim and a sheen, over the body's colour broken up by fine noise that
 *    sticks to the surface. The eyes are painted on the face from the same spot, so they turn
 *    with it.
 * 3. Fur: thousands of short strands grow out along the surface's normal from fixed roots. Those
 *    on the visible side are drawn as fading lines over the lit body, and along the outline they
 *    stand out against the background, which gives the soft fuzzy edge.
 *
 * Smooth finishes (plastic, smooth, flat) skip the fur and are drawn at twice the size, then
 * scaled down, for clean edges.
 */

export type Shading = 'fabric' | 'plastic' | 'crisp' | 'smooth' | 'flat';
export type Hat = 'none' | 'beret' | 'beanie' | 'party' | 'crown';
export type Glasses = 'none' | 'round' | 'square';

export type Look = {
  type: ShapeName;
  shading: Shading;
  hat: Hat;
  glasses: Glasses;
  color: string | undefined;
  ink: string;
  furLength: number;
  brightness: number;
  saturation: number;
  shadow: number;
  backLight: number;
  sheen: number;
  depth: number;
  edgeFuzz: number;
};

/** Where the body is and how its face looks this frame. */
export type Pose = {
  y: number;
  sx: number;
  sy: number;
  yaw: number;
  pitch: number;
  roll: number;
  /** Eyes: where they look on the face, how open they are, and how much they are arcs. */
  lookX: number;
  lookY: number;
  open: number;
  happy: number;
  sleep: number;
};

type Rgb = [number, number, number];

function parse(hex: string): Rgb {
  let h = hex.replace('#', '');
  if (h.length === 3) h = [...h].map((c) => c + c).join('');
  const v = parseInt(h, 16) || 0;
  return [((v >> 16) & 255) / 255, ((v >> 8) & 255) / 255, (v & 255) / 255];
}

/** Brightness and saturation, as multipliers around the colour's own. */
/**
 * The colour tuning, applied to the lit colour (so shade deepens as well as darkens): saturation
 * and brightness as multipliers, 1.59 and 1.2 at their defaults.
 */
function grade(c: Rgb, brightness: number, saturation: number) {
  const l = c[0] * 0.2126 + c[1] * 0.7152 + c[2] * 0.0722;
  const s = saturation * 1.59;
  const b = brightness * 1.2;
  c[0] = Math.max(0, (l + (c[0] - l) * s) * b);
  c[1] = Math.max(0, (l + (c[1] - l) * s) * b);
  c[2] = Math.max(0, (l + (c[2] - l) * s) * b);
}

/** The light: a key from the upper left and in front, with the view straight on. */
const LX = -0.55;
const LY = 0.6;
const LZ = 0.58;
const HX = LX;
const HY = LY;
const HZ = LZ + 1;
const HL = Math.hypot(HX, HY, HZ);
/** The highlight's half vector: a light up and to the left, nearer the view. */
const SX0 = -0.3;
const SY0 = 0.35;
const SZ0 = 0.89 + 1;
const SL = Math.hypot(SX0, SY0, SZ0);
const SX = SX0 / SL;
const SY = SY0 / SL;
const SZ = SZ0 / SL;

function hash(x: number, y: number) {
  let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263)) | 0;
  h = Math.imul(h ^ (h >>> 13), 1274126177);
  return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}

/**
 * The fur's grain: a 256 × 256 tile of fine noise with a little coarser clumping, worked out
 * once and looked up by the spot on the face.
 */
const GRAIN = (() => {
  const tile = new Float32Array(256 * 256);
  for (let y = 0; y < 256; y++)
    for (let x = 0; x < 256; x++) {
      // The clumps wrap round the tile: 256 / 8 = 32 cells across.
      const cx = x / 8;
      const cy = y / 8;
      const ix = Math.floor(cx);
      const iy = Math.floor(cy);
      const fx = cx - ix;
      const fy = cy - iy;
      const sx = fx * fx * (3 - 2 * fx);
      const sy = fy * fy * (3 - 2 * fy);
      const h = (a: number, b: number) => hash(a & 31, b & 31);
      const a = h(ix, iy);
      const b = h(ix + 1, iy);
      const c = h(ix, iy + 1);
      const d = h(ix + 1, iy + 1);
      const clump = a + (b - a) * sx + (c - a) * sy + (a - b - c + d) * sx * sy;
      tile[y * 256 + x] = (hash(x, y + 5003) - 0.5) * 0.8 + (clump - 0.5) * 0.25;
    }
  return tile;
})();

/** Materials kept per pixel. */
const BODY = 1;
const PART = 2;
const CLOTH = 3;
const GOLD = 4;
const FRAME = 5;

type Solid = {
  kind: 'ball' | 'cone' | 'tube';
  /** Centre (or base), in the body's own units, and its axes (columns: x, y, z), scaled. */
  at: [number, number, number];
  axes: number[];
  material: number;
  color: Rgb;
  /** Paints stripes or a cuff: a second colour by height along the solid. */
  stripes?: { color: Rgb; every: number };
  /** Only the part above this local height is solid (a dome). */
  clip?: number;
};

export class Renderer {
  private w = 0;
  private h = 0;
  private zb = new Float32Array(0);
  private nx = new Float32Array(0);
  private ny = new Float32Array(0);
  private nz = new Float32Array(0);
  private u = new Float32Array(0);
  private v = new Float32Array(0);
  private cr = new Float32Array(0);
  private cg = new Float32Array(0);
  private cb = new Float32Array(0);
  private mat = new Uint8Array(0);
  private side = new Uint8Array(0);
  private image: ImageData | null = null;
  /** The box of pixels drawn this frame: left, top, right, bottom. */
  private box = [0, 0, 0, 0];
  private scratch: HTMLCanvasElement | null = null;
  private wpos = new Float32Array(0);
  private wnrm = new Float32Array(0);

  constructor(private ctx: CanvasRenderingContext2D) {}

  /**
   * Draws one frame. `size` is the avatar's size in device px; the canvas is 1.5 times that,
   * the body in the avatar's box within it.
   */
  draw(look: Look, pose: Pose, size: number) {
    const canvas = this.ctx.canvas;
    const smooth = look.shading !== 'fabric' && look.shading !== 'crisp';
    const ss = smooth ? 2 : 1;
    const W = Math.round(canvas.width * ss);
    const H = Math.round(canvas.height * ss);
    this.resize(W, H);
    const scale = size * ss * 0.462;
    const cx = W / 2;
    // The body sits in the avatar's box, a little below the canvas's middle (more room above).
    const cy = H * 0.5693;

    const shape = SHAPES[look.type];
    const grid = Math.max(24, Math.min(64, Math.round((scale * 2.4) / 3.2)));
    const mesh = getMesh(look.type, look.depth, grid);

    // The body's transform: squash about its bottom, turn (yaw, then pitch, then roll), lift.
    const cyw = Math.cos(pose.yaw);
    const syw = Math.sin(pose.yaw);
    const cp = Math.cos(pose.pitch);
    const sp = Math.sin(pose.pitch);
    const cr = Math.cos(pose.roll);
    const sr = Math.sin(pose.roll);
    // R = Rz(roll) * Rx(pitch) * Ry(yaw)
    const Ry = [cyw, 0, syw, 0, 1, 0, -syw, 0, cyw];
    const Rx = [1, 0, 0, 0, cp, -sp, 0, sp, cp];
    const Rz = [cr, -sr, 0, sr, cr, 0, 0, 0, 1];
    const R = mul(Rz, mul(Rx, Ry));
    const sz = (pose.sx + 1) / 2;
    const S = [pose.sx, 0, 0, 0, pose.sy, 0, 0, 0, sz];
    const L = mul(R, S);
    const pivot = mesh.bottom;
    // p' = L (p - pivot) + pivot + lift; the turn's centre is the body's middle, so lift that too.
    const ox = -L[1] * pivot;
    const oy = pivot - L[4] * pivot + pose.y;
    const oz = -L[7] * pivot;

    const transform = (x: number, y: number, z: number, out: Float32Array, o: number) => {
      out[o] = L[0] * x + L[1] * y + L[2] * z + ox;
      out[o + 1] = L[3] * x + L[4] * y + L[5] * z + oy;
      out[o + 2] = L[6] * x + L[7] * y + L[8] * z + oz;
    };

    // 1. The body.
    const { pos, nrm, tris } = mesh;
    const vcount = pos.length / 3;
    if (this.wpos.length < pos.length) {
      this.wpos = new Float32Array(pos.length);
      this.wnrm = new Float32Array(pos.length);
    }
    const wp = this.wpos;
    const wn = this.wnrm;
    // Normals turn with R and lean with the squash (inverse scale).
    const isx = 1 / pose.sx;
    const isy = 1 / pose.sy;
    const isz = 1 / sz;
    for (let i = 0; i < vcount; i++) {
      const o = i * 3;
      transform(pos[o], pos[o + 1], pos[o + 2], wp, o);
      const ax = nrm[o] * isx;
      const ay = nrm[o + 1] * isy;
      const az = nrm[o + 2] * isz;
      let x = R[0] * ax + R[1] * ay + R[2] * az;
      let y = R[3] * ax + R[4] * ay + R[5] * az;
      let z = R[6] * ax + R[7] * ay + R[8] * az;
      const l = Math.hypot(x, y, z) || 1;
      x /= l;
      y /= l;
      z /= l;
      wn[o] = x;
      wn[o + 1] = y;
      wn[o + 2] = z;
    }
    const half = vcount / 2;
    this.fill(tris, wp, wn, pos, half, scale, cx, cy);

    // Hard parts and what the avatar wears.
    const solids = this.solids(look, mesh);
    const base = parse(look.color ?? shape.color);
    const bright = look.brightness;
    const sat = look.saturation;
    for (const solid of solids) this.cast(solid, L, ox, oy, oz, scale, cx, cy);

    // 2. Light every covered pixel.
    const img = this.image!;
    const data = img.data;
    data.fill(0);
    const ink = parse(look.ink);
    const eyes = shape.eyes;
    const fur = look.shading === 'fabric' || look.shading === 'crisp';
    const grain = scale * (look.shading === 'crisp' ? 1.1 : 0.85);
    const noiseAmp =
      look.shading === 'fabric'
        ? 0.2
        : look.shading === 'crisp'
          ? 0.36
          : look.shading === 'smooth'
            ? 0.1
            : 0;
    const aa = 1.2 / scale;
    const zb = this.zb;
    const col: Rgb = [0, 0, 0];
    const x0 = Math.max(0, this.box[0]);
    const x1 = Math.min(W - 1, this.box[2]);
    for (let y = Math.max(0, this.box[1]); y <= Math.min(H - 1, this.box[3]); y++) {
      for (let i = y * W + x0; i <= y * W + x1; i++) {
        const m = this.mat[i];
        if (!m) continue;
        let r: number;
        let g: number;
        let b: number;
        const nX = this.nx[i];
        const nY = this.ny[i];
        const nZ = this.nz[i];
        if (m === BODY) {
          const pu = this.u[i];
          const pv = this.v[i];
          r = base[0];
          g = base[1];
          b = base[2];
          if (noiseAmp) {
            const gx = Math.floor(pu * grain + 4096) & 255;
            const gy = (Math.floor(pv * grain + 4096) + this.side[i] * 97) & 255;
            const k = 1 + noiseAmp * GRAIN[(gy << 8) | gx];
            r *= k;
            g *= k;
            b *= k;
          }
          this.light(r, g, b, nX, nY, nZ, look, col);
          if (this.side[i]) {
            const e = eyeCover(pu, pv, eyes, pose, aa);
            if (e > 0) {
              col[0] += (ink[0] - col[0]) * e;
              col[1] += (ink[1] - col[1]) * e;
              col[2] += (ink[2] - col[2]) * e;
            }
          }
        } else {
          r = this.cr[i];
          g = this.cg[i];
          b = this.cb[i];
          this.lightPart(r, g, b, nX, nY, nZ, m, look, col);
          if (m === PART) grade(col, bright, sat);
        }
        const o = i * 4;
        data[o] = col[0] * 255;
        data[o + 1] = col[1] * 255;
        data[o + 2] = col[2] * 255;
        data[o + 3] = 255;
      }
    }

    // 3. Fur.
    if (fur && look.furLength > 0) {
      const crisp = look.shading === 'crisp';
      const area = scale * scale;
      const density = Math.round(Math.min(1500, Math.max(220, area * (crisp ? 0.3 : 0.22))));
      const roots = getRoots(`${look.type}|${look.depth.toFixed(2)}|${grid}`, mesh, density);
      const rd = roots.data;
      const strand = (crisp ? 0.022 : 0.032) * look.furLength;
      const fuzz = look.edgeFuzz;
      const tmp = new Float32Array(3);
      for (let k = 0; k < roots.count; k++) {
        const o = k * ROOT_STRIDE;
        const ax = rd[o + 3] * isx;
        const ay = rd[o + 4] * isy;
        const az = rd[o + 5] * isz;
        let nX = R[0] * ax + R[1] * ay + R[2] * az;
        let nY = R[3] * ax + R[4] * ay + R[5] * az;
        let nZ = R[6] * ax + R[7] * ay + R[8] * az;
        const nl = Math.hypot(nX, nY, nZ) || 1;
        nX /= nl;
        nY /= nl;
        nZ /= nl;
        if (nZ < -0.15) continue;
        // Strands facing the viewer add only texture: keep one in three.
        if (nZ > 0.55 && k % 3) continue;
        transform(rd[o], rd[o + 1], rd[o + 2], tmp, 0);
        const X0 = cx + tmp[0] * scale;
        const Y0 = cy - tmp[1] * scale;
        const pxi = X0 | 0;
        const pyi = Y0 | 0;
        if (pxi < 0 || pyi < 0 || pxi >= W || pyi >= H) continue;
        const pi = pyi * W + pxi;
        if (zb[pi] > tmp[2] + 0.04) continue;
        const rim = 1 - Math.abs(nZ);
        const len = strand * rd[o + 6] * (1 + fuzz * rim * 0.9);
        const jx = rd[o + 8] * 0.35;
        const jy = rd[o + 9] * 0.35 - 0.3;
        const dX = (nX + jx) * len * scale;
        const dY = -(nY + jy) * len * scale;
        // Colour: the body's, lit by the strand's normal, a little lighter or darker by strand.
        const shadeK = 0.86 + rd[o + 7] * 0.3;
        let r = base[0] * shadeK;
        let g = base[1] * shadeK;
        let b = base[2] * shadeK;
        this.light(r, g, b, nX, nY, nZ, look, col);
        r = col[0];
        g = col[1];
        b = col[2];
        if (rd[o + 2] > 0) {
          const e = eyeCover(rd[o], rd[o + 1], eyes, pose, aa);
          if (e > 0) {
            r += (ink[0] - r) * e;
            g += (ink[1] - g) * e;
            b += (ink[2] - b) * e;
          }
        }
        const steps = Math.max(1, Math.ceil(Math.max(Math.abs(dX), Math.abs(dY))));
        const alpha0 = (crisp ? 0.75 : 0.55) * (0.6 + 0.4 * rim);
        for (let s = 1; s <= steps; s++) {
          const t = s / steps;
          const X = (X0 + dX * t) | 0;
          const Y = (Y0 + dY * t) | 0;
          if (X < 0 || Y < 0 || X >= W || Y >= H) break;
          const a = alpha0 * (1 - t * (0.35 + 0.45 * (1 - fuzz * 0.5)));
          const q = (Y * W + X) * 4;
          const da = data[q + 3] / 255;
          const lift = 1 + t * 0.12;
          const oa = a + da * (1 - a);
          const k1 = a / oa;
          data[q] = (r * lift * 255 - data[q]) * k1 + data[q];
          data[q + 1] = (g * lift * 255 - data[q + 1]) * k1 + data[q + 1];
          data[q + 2] = (b * lift * 255 - data[q + 2]) * k1 + data[q + 2];
          data[q + 3] = oa * 255;
        }
      }
    }

    // Out to the canvas.
    const ctx = this.ctx;
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    if (ss === 1) {
      ctx.putImageData(img, 0, 0);
    } else {
      if (!this.scratch) this.scratch = document.createElement('canvas');
      const s = this.scratch;
      if (s.width !== W || s.height !== H) {
        s.width = W;
        s.height = H;
      }
      s.getContext('2d')!.putImageData(img, 0, 0);
      ctx.imageSmoothingEnabled = true;
      ctx.imageSmoothingQuality = 'high';
      ctx.drawImage(s, 0, 0, canvas.width, canvas.height);
    }
  }

  private resize(W: number, H: number) {
    this.box[0] = W;
    this.box[1] = H;
    this.box[2] = -1;
    this.box[3] = -1;
    if (this.w === W && this.h === H) {
      this.zb.fill(-1e9);
      this.mat.fill(0);
      return;
    }
    this.w = W;
    this.h = H;
    const n = W * H;
    this.zb = new Float32Array(n).fill(-1e9);
    this.nx = new Float32Array(n);
    this.ny = new Float32Array(n);
    this.nz = new Float32Array(n);
    this.u = new Float32Array(n);
    this.v = new Float32Array(n);
    this.cr = new Float32Array(n);
    this.cg = new Float32Array(n);
    this.cb = new Float32Array(n);
    this.mat = new Uint8Array(n);
    this.side = new Uint8Array(n);
    this.image = new ImageData(W, H);
  }

  /** Fills the body's triangles into the buffers. */
  private fill(
    tris: Uint32Array,
    wp: Float32Array,
    wn: Float32Array,
    op: Float32Array,
    half: number,
    scale: number,
    cx: number,
    cy: number,
  ) {
    const W = this.w;
    const H = this.h;
    const zb = this.zb;
    for (let t = 0; t < tris.length; t += 3) {
      const ia = tris[t] * 3;
      const ib = tris[t + 1] * 3;
      const ic = tris[t + 2] * 3;
      const ax = wp[ia];
      const ay = wp[ia + 1];
      const bx = wp[ib];
      const by = wp[ib + 1];
      const cxw = wp[ic];
      const cyw = wp[ic + 1];
      // Facing away (in the world, y up): skip.
      const area = (bx - ax) * (cyw - ay) - (cxw - ax) * (by - ay);
      if (area <= 0) continue;
      const X0 = cx + ax * scale;
      const Y0 = cy - ay * scale;
      const X1 = cx + bx * scale;
      const Y1 = cy - by * scale;
      const X2 = cx + cxw * scale;
      const Y2 = cy - cyw * scale;
      const minX = Math.max(0, Math.floor(Math.min(X0, X1, X2)));
      const maxX = Math.min(W - 1, Math.ceil(Math.max(X0, X1, X2)));
      const minY = Math.max(0, Math.floor(Math.min(Y0, Y1, Y2)));
      const maxY = Math.min(H - 1, Math.ceil(Math.max(Y0, Y1, Y2)));
      if (minX > maxX || minY > maxY) continue;
      const box = this.box;
      if (minX < box[0]) box[0] = minX;
      if (minY < box[1]) box[1] = minY;
      if (maxX > box[2]) box[2] = maxX;
      if (maxY > box[3]) box[3] = maxY;
      // In screen space (y down) the winding flips.
      const D = (X1 - X0) * (Y2 - Y0) - (X2 - X0) * (Y1 - Y0);
      if (D === 0) continue;
      const invD = 1 / D;
      const back = tris[t] >= half ? 0 : 1;
      const za = wp[ia + 2];
      const zbv = wp[ib + 2];
      const zc = wp[ic + 2];
      for (let y = minY; y <= maxY; y++) {
        const py = y + 0.5;
        for (let x = minX; x <= maxX; x++) {
          const px = x + 0.5;
          const w1 = ((px - X0) * (Y2 - Y0) - (X2 - X0) * (py - Y0)) * invD;
          const w2 = ((X1 - X0) * (py - Y0) - (px - X0) * (Y1 - Y0)) * invD;
          const w0 = 1 - w1 - w2;
          if (w0 < -1e-4 || w1 < -1e-4 || w2 < -1e-4) continue;
          const z = za * w0 + zbv * w1 + zc * w2;
          const i = y * W + x;
          if (z <= zb[i]) continue;
          zb[i] = z;
          this.nx[i] = wn[ia] * w0 + wn[ib] * w1 + wn[ic] * w2;
          this.ny[i] = wn[ia + 1] * w0 + wn[ib + 1] * w1 + wn[ic + 1] * w2;
          this.nz[i] = wn[ia + 2] * w0 + wn[ib + 2] * w1 + wn[ic + 2] * w2;
          this.u[i] = op[ia] * w0 + op[ib] * w1 + op[ic] * w2;
          this.v[i] = op[ia + 1] * w0 + op[ib + 1] * w1 + op[ic + 1] * w2;
          this.mat[i] = BODY;
          this.side[i] = back;
        }
      }
    }
  }

  /** The body's light, for its colour r, g, b and normal n, into `out`. */
  /**
   * The body's light, for its colour r, g, b and normal n, into `out`: lit, then tuned (so shade
   * deepens as well as darkens), then the lit side washed paler and the highlights added.
   */
  private light(
    r: number,
    g: number,
    b: number,
    nx: number,
    ny: number,
    nz: number,
    look: Look,
    out: Rgb,
  ) {
    const l = Math.hypot(nx, ny, nz) || 1;
    nx /= l;
    ny /= l;
    nz /= l;
    const ndl = nx * LX + ny * LY + nz * LZ;
    const shading = look.shading;
    const shadow = look.shadow;
    let k: number;
    let spec = 0;
    let wash = 0;
    if (shading === 'flat') {
      k = ndl > 0.05 ? 0.84 : 0.84 - 0.16 * shadow;
    } else {
      const wrap = shading === 'plastic' ? 0.2 : 0.25;
      const diff = Math.min(1, Math.max(0, (ndl + wrap) / (1 + wrap)));
      k =
        shading === 'plastic'
          ? 1 - shadow * (1 - (0.3 + 0.62 * diff))
          : (1 - shadow * (1 - (0.18 + 0.82 * diff))) * 1.02;
      const lit = ndl > 0 ? ndl : 0;
      const lit2 = lit * lit;
      wash = shading === 'plastic' ? lit2 * 0.4 : lit2 * lit2 * lit2 * 0.6;
      // Highlights come from a light a little more in front.
      const ndh = nx * SX + ny * SY + nz * SZ;
      if (shading === 'plastic')
        spec = Math.pow(Math.max(0, ndh), 60) * 0.3 + Math.pow(Math.max(0, ndh), 10) * 0.1;
      else if (look.sheen > 0 && ndh > 0) spec = Math.pow(ndh, 10) * look.sheen * 0.35;
    }
    out[0] = r * k;
    out[1] = g * k;
    out[2] = b * k;
    // Shade deepens: the darker, the more saturated.
    grade(out, look.brightness, look.saturation * (1 + Math.max(0, 0.9 - k) * 0.5));
    const lum = Math.min(1, (out[0] * 0.2126 + out[1] * 0.7152 + out[2] * 0.0722) * 1.1 + 0.06);
    const edge = 1 - Math.max(0, nz);
    const rim =
      edge *
      edge *
      edge *
      look.backLight *
      (shading === 'smooth' ? 0.7 : 0.16) *
      Math.max(0, 0.35 - nx * 0.65 + ny * 0.4);
    for (let c = 0; c < 3; c++) {
      const v = out[c] + (lum - out[c]) * wash;
      out[c] = v + spec + rim * (v * 0.6 + 0.25);
    }
  }

  /** Hard parts: glossy; cloth: soft; gold: metal; frames: dark gloss. */
  private lightPart(
    r: number,
    g: number,
    b: number,
    nx: number,
    ny: number,
    nz: number,
    m: number,
    look: Look,
    out: Rgb,
  ) {
    const ndl = nx * LX + ny * LY + nz * LZ;
    const ndh = (nx * HX + ny * HY + nz * HZ) / HL;
    const shadow = look.shadow;
    if (m === CLOTH) {
      const diff = Math.max(0, (ndl + 0.5) / 1.5);
      const k = 1 - shadow * (1 - (0.45 + 0.55 * diff)) * 0.9;
      out[0] = r * k;
      out[1] = g * k;
      out[2] = b * k;
      return;
    }
    const diff = Math.max(0, (ndl + 0.2) / 1.2);
    const k = 1 - shadow * (1 - (0.4 + 0.6 * diff)) * 0.9;
    const gloss = m === GOLD ? 0.9 : m === FRAME ? 0.5 : 0.7;
    const spec = Math.pow(Math.max(0, ndh), m === GOLD ? 24 : 50) * gloss;
    const tint = m === GOLD ? 1 : 0;
    out[0] = r * k + spec * (1 - tint + tint * r);
    out[1] = g * k + spec * (1 - tint + tint * g);
    out[2] = b * k + spec * (1 - tint + tint * b);
  }

  /** The solids this avatar wears, in the body's own units. */
  private solids(look: Look, mesh: Mesh): Solid[] {
    const shape = SHAPES[look.type];
    const out: Solid[] = [];
    const body = parse(look.color ?? shape.color);
    const part = shape.partColor
      ? parse(shape.partColor)
      : (body.map((c) => Math.min(1, c * 0.9 + 0.08)) as Rgb);
    for (const a of shape.antennas ?? []) {
      const [fx, fy, fz] = a.from;
      const [tx, ty, tz] = a.to;
      const dx = tx - fx;
      const dy = ty - fy;
      const dz = tz - fz;
      const len = Math.hypot(dx, dy, dz);
      const [ux, uy, uz] = [dx / len, dy / len, dz / len];
      // Any two axes across the stem.
      const [px, py, pz] =
        Math.abs(uy) < 0.9 ? cross(ux, uy, uz, 0, 1, 0) : cross(ux, uy, uz, 1, 0, 0);
      const [qx, qy, qz] = cross(ux, uy, uz, px, py, pz);
      const rad = 0.042;
      out.push({
        kind: 'tube',
        at: [fx, fy, fz],
        axes: [px * rad, py * rad, pz * rad, dx, dy, dz, qx * rad, qy * rad, qz * rad],
        material: PART,
        color: part,
      });
      out.push({
        kind: 'ball',
        at: [tx, ty, tz],
        axes: [a.ball, 0, 0, 0, a.ball, 0, 0, 0, a.ball],
        material: PART,
        color: part,
      });
    }
    for (const e of shape.ears ?? []) {
      out.push({
        kind: 'ball',
        at: [e.at[0], e.at[1], 0],
        axes: [0.09, 0, 0, 0, e.radius, 0, 0, 0, e.radius],
        material: PART,
        color: part,
      });
    }

    const top = mesh.top;
    const hatColor: Record<Exclude<Hat, 'none'>, Rgb> = {
      beret: parse('#2b2d42'),
      beanie: parse('#e25a4f'),
      party: parse('#ffca3a'),
      crown: parse('#f2b632'),
    };
    if (look.hat === 'beret') {
      const tilt = 0.28;
      const c = Math.cos(tilt);
      const s = Math.sin(tilt);
      out.push({
        kind: 'ball',
        at: [0.08, top - 0.08, 0],
        axes: [0.62 * c, 0.62 * s, 0, -0.17 * s, 0.17 * c, 0, 0, 0, 0.6],
        material: CLOTH,
        color: hatColor.beret,
      });
      out.push({
        kind: 'ball',
        at: [0.08 - 0.17 * s, top + 0.09, 0],
        axes: [0.05, 0, 0, 0, 0.07, 0, 0, 0, 0.05],
        material: CLOTH,
        color: hatColor.beret,
      });
    } else if (look.hat === 'beanie') {
      out.push({
        kind: 'ball',
        at: [0, top - 0.3, 0],
        axes: [0.66, 0, 0, 0, 0.56, 0, 0, 0, 0.56],
        material: CLOTH,
        color: hatColor.beanie,
        clip: 0,
        stripes: { color: hatColor.beanie.map((v) => v * 0.82) as Rgb, every: 0.18 },
      });
      out.push({
        kind: 'tube',
        at: [0, top - 0.4, 0],
        axes: [0.69, 0, 0, 0, 0.17, 0, 0, 0, 0.6],
        material: CLOTH,
        color: hatColor.beanie.map((v) => v * 0.86) as Rgb,
      });
      out.push({
        kind: 'ball',
        at: [0, top + 0.3, 0],
        axes: [0.15, 0, 0, 0, 0.15, 0, 0, 0, 0.15],
        material: CLOTH,
        color: parse('#f6f1ea'),
      });
    } else if (look.hat === 'party') {
      const tilt = -0.22;
      const c = Math.cos(tilt);
      const s = Math.sin(tilt);
      const h = 0.78;
      out.push({
        kind: 'cone',
        at: [0.1, top - 0.16, 0],
        axes: [0.3 * c, 0.3 * s, 0, -h * s, h * c, 0, 0, 0, 0.3],
        material: CLOTH,
        color: hatColor.party,
        stripes: { color: parse('#ff5d8f'), every: 0.22 },
      });
      out.push({
        kind: 'ball',
        at: [0.1 - h * s, top - 0.16 + h * c, 0],
        axes: [0.09, 0, 0, 0, 0.09, 0, 0, 0, 0.09],
        material: CLOTH,
        color: parse('#5ec8ff'),
      });
    } else if (look.hat === 'crown') {
      const y0 = top - 0.16;
      out.push({
        kind: 'tube',
        at: [0, y0, 0],
        axes: [0.42, 0, 0, 0, 0.16, 0, 0, 0, 0.3],
        material: GOLD,
        color: hatColor.crown,
      });
      for (let i = 0; i < 5; i++) {
        const a = -Math.PI / 2 + ((i - 2) * Math.PI) / 5.2;
        const x = Math.cos(a) * 0.42;
        const z = -Math.sin(a) * 0.3;
        out.push({
          kind: 'cone',
          at: [x, y0 + 0.14, z],
          axes: [0.08, 0, 0, 0, 0.2, 0, 0, 0, 0.08],
          material: GOLD,
          color: hatColor.crown,
        });
        out.push({
          kind: 'ball',
          at: [x, y0 + 0.36, z],
          axes: [0.045, 0, 0, 0, 0.045, 0, 0, 0, 0.045],
          material: GOLD,
          color: hatColor.crown,
        });
      }
    }

    if (look.glasses !== 'none') {
      const eyes = shape.eyes;
      const frame = parse('#1d1b22');
      const ring = Math.max(eyes.rx, eyes.ry) * 1.65;
      const squareFrame = look.glasses === 'square';
      for (const sideX of [-1, 1]) {
        const ex = sideX * eyes.gap;
        const ey = eyes.y;
        const z = mesh.heightAt(ex, ey) + 0.03;
        const n = 26;
        for (let i = 0; i < n; i++) {
          const a = (i / n) * Math.PI * 2;
          let x = Math.cos(a);
          let y = Math.sin(a);
          if (squareFrame) {
            const m = Math.max(Math.abs(x), Math.abs(y));
            x = (x / m) * 0.92;
            y = (y / m) * 0.8;
          }
          const px = ex + x * ring;
          const py = ey + y * ring;
          const pz = Math.max(z, mesh.heightAt(px, py) + 0.03);
          out.push({
            kind: 'ball',
            at: [px, py, pz],
            axes: [0.034, 0, 0, 0, 0.034, 0, 0, 0, 0.034],
            material: FRAME,
            color: frame,
          });
        }
      }
      // The bridge.
      const by = eyes.y + 0.04;
      const bz = mesh.heightAt(0, by) + 0.04;
      for (let i = 0; i <= 6; i++) {
        const x = (-0.5 + i / 6) * (eyes.gap * 2 - ring * 1.7);
        out.push({
          kind: 'ball',
          at: [x, by + 0.02 * Math.cos((i / 6) * Math.PI - Math.PI / 2), bz],
          axes: [0.03, 0, 0, 0, 0.03, 0, 0, 0, 0.03],
          material: FRAME,
          color: frame,
        });
      }
    }
    return out;
  }

  /** Casts a ray through every pixel of a solid's box, straight into the screen. */
  private cast(
    solid: Solid,
    L: number[],
    ox: number,
    oy: number,
    oz: number,
    scale: number,
    cx: number,
    cy: number,
  ) {
    const W = this.w;
    const H = this.h;
    // World centre and axes.
    const [sx, sy, sz] = solid.at;
    const wx = L[0] * sx + L[1] * sy + L[2] * sz + ox;
    const wy = L[3] * sx + L[4] * sy + L[5] * sz + oy;
    const wz = L[6] * sx + L[7] * sy + L[8] * sz + oz;
    const a = solid.axes;
    // A = L * axes (columns).
    const A = mul(L, [a[0], a[3], a[6], a[1], a[4], a[7], a[2], a[5], a[8]]);
    const inv = invert(A);
    if (!inv) return;
    // Its screen box: the solid fits in a sphere of radius the longest axis (and its height).
    const c0 = Math.hypot(A[0], A[3], A[6]);
    const c1 = Math.hypot(A[1], A[4], A[7]);
    const c2 = Math.hypot(A[2], A[5], A[8]);
    const reach =
      (solid.kind === 'ball' ? Math.max(c0, c1, c2) : c1 + Math.max(c0, c2)) * scale + 2;
    const mx = cx + wx * scale;
    const my = cy - wy * scale;
    const minX = Math.max(0, Math.floor(mx - reach));
    const maxX = Math.min(W - 1, Math.ceil(mx + reach));
    const minY = Math.max(0, Math.floor(my - reach));
    const maxY = Math.min(H - 1, Math.ceil(my + reach));
    const box = this.box;
    if (minX < box[0]) box[0] = minX;
    if (minY < box[1]) box[1] = minY;
    if (maxX > box[2]) box[2] = maxX;
    if (maxY > box[3]) box[3] = maxY;
    // The ray's direction in local units: A^-1 (0, 0, 1).
    const dx = inv[2];
    const dy = inv[5];
    const dz = inv[8];
    const zb = this.zb;
    for (let y = minY; y <= maxY; y++) {
      const py = (cy - (y + 0.5)) / scale - wy;
      for (let x = minX; x <= maxX; x++) {
        const px = (x + 0.5 - cx) / scale - wx;
        const qx = inv[0] * px + inv[1] * py;
        const qy = inv[3] * px + inv[4] * py;
        const qz = inv[6] * px + inv[7] * py;
        let t = NaN;
        let hx = 0;
        let hy = 0;
        let hz = 0;
        let nxl = 0;
        let nyl = 0;
        let nzl = 0;
        if (solid.kind === 'ball') {
          const A2 = dx * dx + dy * dy + dz * dz;
          const B = 2 * (qx * dx + qy * dy + qz * dz);
          const C = qx * qx + qy * qy + qz * qz - 1;
          const disc = B * B - 4 * A2 * C;
          if (disc < 0) continue;
          const sq = Math.sqrt(disc);
          for (const tt of [(-B + sq) / (2 * A2), (-B - sq) / (2 * A2)]) {
            const ly = qy + dy * tt;
            if (solid.clip !== undefined && ly < solid.clip) continue;
            t = tt;
            hx = qx + dx * tt;
            hy = ly;
            hz = qz + dz * tt;
            nxl = hx;
            nyl = hy;
            nzl = hz;
            break;
          }
        } else if (solid.kind === 'cone') {
          // x² + z² = (1 - y)², 0 ≤ y ≤ 1.
          const ky = 1 - qy;
          const A2 = dx * dx + dz * dz - dy * dy;
          const B = 2 * (qx * dx + qz * dz + ky * dy);
          const C = qx * qx + qz * qz - ky * ky;
          const disc = B * B - 4 * A2 * C;
          if (disc < 0 || Math.abs(A2) < 1e-9) continue;
          const sq = Math.sqrt(disc);
          const t1 = (-B + sq) / (2 * A2);
          const t2 = (-B - sq) / (2 * A2);
          for (const tt of t1 > t2 ? [t1, t2] : [t2, t1]) {
            const ly = qy + dy * tt;
            if (ly < 0 || ly > 1) continue;
            t = tt;
            hx = qx + dx * tt;
            hy = ly;
            hz = qz + dz * tt;
            nxl = hx;
            nyl = 1 - ly;
            nzl = hz;
            break;
          }
        } else {
          // Tube: x² + z² = 1, 0 ≤ y ≤ 1, open at both ends.
          const A2 = dx * dx + dz * dz;
          const B = 2 * (qx * dx + qz * dz);
          const C = qx * qx + qz * qz - 1;
          const disc = B * B - 4 * A2 * C;
          if (disc < 0 || A2 < 1e-9) continue;
          const sq = Math.sqrt(disc);
          const t1 = (-B + sq) / (2 * A2);
          const t2 = (-B - sq) / (2 * A2);
          let first = true;
          for (const tt of t1 > t2 ? [t1, t2] : [t2, t1]) {
            const ly = qy + dy * tt;
            if (ly >= 0 && ly <= 1) {
              t = tt;
              hx = qx + dx * tt;
              hy = ly;
              hz = qz + dz * tt;
              const f = first ? 1 : -1;
              nxl = hx * f;
              nyl = 0;
              nzl = hz * f;
              break;
            }
            first = false;
          }
        }
        if (Number.isNaN(t)) continue;
        const z = wz + t;
        const i = y * W + x;
        if (z <= zb[i]) continue;
        // World normal: A^-T times the local one.
        let nX = inv[0] * nxl + inv[3] * nyl + inv[6] * nzl;
        let nY = inv[1] * nxl + inv[4] * nyl + inv[7] * nzl;
        let nZ = inv[2] * nxl + inv[5] * nyl + inv[8] * nzl;
        const l = Math.hypot(nX, nY, nZ) || 1;
        nX /= l;
        nY /= l;
        nZ /= l;
        zb[i] = z;
        this.nx[i] = nX;
        this.ny[i] = nY;
        this.nz[i] = nZ;
        this.mat[i] = solid.material;
        let c = solid.color;
        if (solid.stripes && Math.floor(hy / solid.stripes.every) % 2) c = solid.stripes.color;
        this.cr[i] = c[0];
        this.cg[i] = c[1];
        this.cb[i] = c[2];
      }
    }
  }
}

/** How much of the eyes covers a point (u, v) of the face, 0–1, with an edge `aa` wide. */
function eyeCover(
  u: number,
  v: number,
  eyes: { y: number; gap: number; rx: number; ry: number },
  pose: Pose,
  aa: number,
) {
  const dy0 = v - (eyes.y + pose.lookY);
  if (Math.abs(dy0) > eyes.ry * 1.6) return 0;
  const lx = pose.lookX;
  const dxl = Math.abs(u - lx);
  if (dxl > eyes.gap + eyes.rx * 2) return 0;
  let best = 0;
  for (const side of [-1, 1]) {
    const dx = u - (side * eyes.gap + lx);
    const dy = dy0;
    let cover = 0;
    const arcs = Math.max(pose.happy, pose.sleep);
    if (arcs < 1) {
      // Open eye: an ellipse that flattens to a dash as it closes.
      const open = Math.max(0, Math.min(1, pose.open));
      const ry = Math.max(eyes.ry * open, eyes.ry * 0.17);
      const rx = eyes.rx * (1 + (1 - open) * 0.35);
      const q = Math.hypot(dx / rx, dy / ry);
      const d = (q - 1) * Math.min(rx, ry);
      cover = Math.max(0, Math.min(1, 0.5 - d / aa)) * (1 - arcs);
    }
    if (arcs > 0) {
      // Arcs: ^ when happy (the top of a ring), ‿ asleep (its bottom).
      const happy = pose.happy >= pose.sleep;
      const ring = eyes.rx * 1.05;
      const thick = eyes.rx * 0.32;
      const ccy = happy ? -ring * 0.45 : ring * 0.55;
      const px = dx;
      const py = dy - ccy;
      const ang = Math.atan2(py, px);
      const spread = 1.05;
      const mid = happy ? Math.PI / 2 : -Math.PI / 2;
      let da = ang - mid;
      while (da > Math.PI) da -= Math.PI * 2;
      while (da < -Math.PI) da += Math.PI * 2;
      let d: number;
      if (Math.abs(da) <= spread) {
        d = Math.abs(Math.hypot(px, py) - ring) - thick;
      } else {
        const ea = mid + Math.sign(da) * spread;
        d = Math.hypot(px - Math.cos(ea) * ring, py - Math.sin(ea) * ring) - thick;
      }
      cover = Math.max(cover, Math.max(0, Math.min(1, 0.5 - d / aa)) * arcs);
    }
    best = Math.max(best, cover);
  }
  return best;
}

function mul(a: number[], b: number[]) {
  const o = new Array<number>(9);
  for (let r = 0; r < 3; r++)
    for (let c = 0; c < 3; c++)
      o[r * 3 + c] = a[r * 3] * b[c] + a[r * 3 + 1] * b[3 + c] + a[r * 3 + 2] * b[6 + c];
  return o;
}

function invert(m: number[]) {
  const [a, b, c, d, e, f, g, h, i] = m;
  const A = e * i - f * h;
  const B = -(d * i - f * g);
  const C = d * h - e * g;
  const det = a * A + b * B + c * C;
  if (Math.abs(det) < 1e-12) return null;
  const k = 1 / det;
  return [
    A * k,
    -(b * i - c * h) * k,
    (b * f - c * e) * k,
    B * k,
    (a * i - c * g) * k,
    -(a * f - c * d) * k,
    C * k,
    -(a * h - b * g) * k,
    (a * e - b * d) * k,
  ];
}

function cross(ax: number, ay: number, az: number, bx: number, by: number, bz: number) {
  const x = ay * bz - az * by;
  const y = az * bx - ax * bz;
  const z = ax * by - ay * bx;
  const l = Math.hypot(x, y, z) || 1;
  return [x / l, y / l, z / l] as const;
}
```

### bot-avatars/mesh.ts

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

import { SHAPES, type ShapeName } from './shapes';

/*
 * From a flat outline to a plush body. A square grid is laid over the outline; grid points just
 * outside it are pulled onto it, so the edge is smooth. Each point is lifted to a height that
 * grows from nothing at the outline, round like a cushion's seam, to the body's full thickness
 * a little way in, plus a gentle dome over the whole face. The front is that surface and the back
 * its mirror, joined at the outline: two triangle sheets, each with a normal at every point.
 *
 * Built once per body, thickness and grid size, and shared by every avatar that draws it. The
 * fur's roots are scattered over the same surface, as many as the size asks for.
 */

export type Mesh = {
  /** Points, then normals, 3 numbers each: the front sheet's first, then the back's. */
  pos: Float32Array;
  nrm: Float32Array;
  /** Triangles, as point indices, already wound to face out. */
  tris: Uint32Array;
  /** The lowest point of the outline (the body squashes down onto it) and its top at the middle. */
  bottom: number;
  top: number;
  /** Height of the front surface anywhere on the face (0 outside it). */
  heightAt: (x: number, y: number) => number;
};

export type Roots = {
  /** Per root: x, y, z, nx, ny, nz, length (0–1), shade (0–1), jitter x, y, z. */
  data: Float32Array;
  count: number;
};

const STRIDE = 11;
export const ROOT_STRIDE = STRIDE;

/** How far in from the outline the seam rounds over, and the thickness at full height. */
const SEAM = 0.5;
const THICK = 0.4;

const meshes = new Map<string, Mesh>();
const roots = new Map<string, Roots>();

export function getMesh(name: ShapeName, depth: number, grid: number): Mesh {
  const key = `${name}|${depth.toFixed(2)}|${grid}`;
  let mesh = meshes.get(key);
  if (!mesh) {
    mesh = build(name, depth, grid);
    if (meshes.size > 40) meshes.clear();
    meshes.set(key, mesh);
  }
  return mesh;
}

function build(name: ShapeName, depthScale: number, grid: number): Mesh {
  const shape = SHAPES[name];
  const sdf = shape.sdf;
  const thick = THICK * shape.depth * depthScale;
  const extent = 1.2;
  const step = (extent * 2) / grid;
  const eps = 0.002;

  const grad = (x: number, y: number) => {
    const gx = sdf(x + eps, y) - sdf(x - eps, y);
    const gy = sdf(x, y + eps) - sdf(x, y - eps);
    const l = Math.hypot(gx, gy) || 1;
    return [gx / l, gy / l];
  };
  // The face's swell: a membrane stretched over the outline and pushed out evenly from behind
  // (a Poisson problem, solved on a grid by over-relaxation). Its square root rounds off steeply
  // at the outline, like a stuffed cushion's seam, with no folds where lobes meet.
  const F = 72;
  const cell = (extent * 2) / F;
  const N = F + 1;
  const m = new Float32Array(N * N);
  const inside = new Uint8Array(N * N);
  for (let j = 0; j < N; j++)
    for (let i = 0; i < N; i++)
      inside[j * N + i] = sdf(-extent + i * cell, -extent + j * cell) < 0 ? 1 : 0;
  const load = cell * cell;
  for (let pass = 0; pass < 260; pass++) {
    for (let j = 1; j < F; j++)
      for (let i = 1; i < F; i++) {
        const k = j * N + i;
        if (!inside[k]) continue;
        const next = (m[k - 1] + m[k + 1] + m[k - N] + m[k + N] + load) / 4;
        m[k] += (next - m[k]) * 1.85;
      }
  }
  let peak = 0;
  for (let k = 0; k < m.length; k++) peak = Math.max(peak, m[k]);
  // Wide bodies swell a little more than narrow ones, but never by much.
  const swell = Math.min(1.15, Math.max(0.75, Math.sqrt(peak / 0.2)));
  const membrane = (x: number, y: number) => {
    const fx = (x + extent) / cell;
    const fy = (y + extent) / cell;
    const i = Math.max(0, Math.min(F - 1, Math.floor(fx)));
    const j = Math.max(0, Math.min(F - 1, Math.floor(fy)));
    const tx = fx - i;
    const ty = fy - j;
    const k = j * N + i;
    const a = m[k] + (m[k + 1] - m[k]) * tx;
    const b = m[k + N] + (m[k + N + 1] - m[k + N]) * tx;
    return (a + (b - a) * ty) / peak;
  };
  const heightAt = (x: number, y: number) => {
    const d = -sdf(x, y);
    if (d <= 0) return 0;
    // Close to the outline the membrane's grid is too coarse: there the swell follows the
    // distance in, which the membrane matches a little way inside.
    const near = Math.min(1, d / (cell * 2.5));
    const v = membrane(x, y) * near + Math.min(1, (d / SEAM) * 0.5) * (1 - near);
    return thick * swell * Math.sqrt(Math.max(0, v));
  };

  const n = grid + 1;
  const px = new Float32Array(n * n);
  const py = new Float32Array(n * n);
  const keep = new Int32Array(n * n).fill(-1);
  const near = step * 1.6;
  let count = 0;
  for (let j = 0; j < n; j++) {
    for (let i = 0; i < n; i++) {
      let x = -extent + i * step;
      let y = -extent + j * step;
      let d = sdf(x, y);
      if (d > near) continue;
      if (d > 0) {
        // Pull the point onto the outline.
        for (let k = 0; k < 4 && Math.abs(d) > 1e-4; k++) {
          const [gx, gy] = grad(x, y);
          x -= gx * d;
          y -= gy * d;
          d = sdf(x, y);
        }
      }
      const k = j * n + i;
      px[k] = x;
      py[k] = y;
      keep[k] = count++;
    }
  }

  const pos = new Float32Array(count * 2 * 3);
  const nrm = new Float32Array(count * 2 * 3);
  let bottom = Infinity;
  for (let k = 0; k < n * n; k++) {
    const v = keep[k];
    if (v < 0) continue;
    const x = px[k];
    const y = py[k];
    const h = heightAt(x, y);
    let nx: number;
    let ny: number;
    let nz: number;
    if (h <= 1e-5) {
      // On the outline the surface stands on edge: its normal points straight out.
      [nx, ny] = grad(x, y);
      nz = 0;
    } else {
      const e = step * 0.5;
      const hx = (heightAt(x + e, y) - heightAt(x - e, y)) / (2 * e);
      const hy = (heightAt(x, y + e) - heightAt(x, y - e)) / (2 * e);
      const l = Math.hypot(hx, hy, 1);
      nx = -hx / l;
      ny = -hy / l;
      nz = 1 / l;
    }
    bottom = Math.min(bottom, y);
    const f = v * 3;
    const b = (v + count) * 3;
    pos[f] = x;
    pos[f + 1] = y;
    pos[f + 2] = h;
    nrm[f] = nx;
    nrm[f + 1] = ny;
    nrm[f + 2] = nz;
    pos[b] = x;
    pos[b + 1] = y;
    pos[b + 2] = -h;
    nrm[b] = nx;
    nrm[b + 1] = ny;
    nrm[b + 2] = -nz;
  }

  const tris: number[] = [];
  const push = (a: number, b: number, c: number) => {
    tris.push(a, b, c);
    tris.push(c + count, b + count, a + count);
  };
  for (let j = 0; j < grid; j++) {
    for (let i = 0; i < grid; i++) {
      const a = keep[j * n + i];
      const b = keep[j * n + i + 1];
      const c = keep[(j + 1) * n + i + 1];
      const d = keep[(j + 1) * n + i];
      const all = a >= 0 && b >= 0 && c >= 0 && d >= 0;
      if (all) {
        push(a, b, c);
        push(a, c, d);
      } else {
        // A corner cell: keep the triangle its three points make.
        const corners = [a, b, c, d].filter((v) => v >= 0);
        if (corners.length === 3) push(corners[0], corners[1], corners[2]);
      }
    }
  }
  // Wind each triangle to face out: front ones towards +z, back ones towards -z.
  const out = new Uint32Array(tris);
  for (let t = 0; t < out.length; t += 3) {
    const a = out[t] * 3;
    const b = out[t + 1] * 3;
    const c = out[t + 2] * 3;
    const cross =
      (pos[b] - pos[a]) * (pos[c + 1] - pos[a + 1]) - (pos[c] - pos[a]) * (pos[b + 1] - pos[a + 1]);
    const back = out[t] >= count;
    if (cross < 0 !== back) {
      const s = out[t + 1];
      out[t + 1] = out[t + 2];
      out[t + 2] = s;
    }
  }

  let top = 0;
  for (let y = 1.3; y > -1; y -= 0.005) {
    if (sdf(0, y) < 0) {
      top = y;
      break;
    }
  }
  return { pos, nrm, tris: out, bottom, top, heightAt };
}

/** A small, fixed random series, so the fur looks the same on every load. */
function random(seed: number) {
  let s = seed >>> 0 || 1;
  return () => {
    s ^= s << 13;
    s ^= s >>> 17;
    s ^= s << 5;
    return (s >>> 0) / 4294967296;
  };
}

/**
 * The fur's roots: scattered evenly over the surface, `density` per square unit, each with a
 * length, a shade and a lean of its own.
 */
export function getRoots(key: string, mesh: Mesh, density: number): Roots {
  const id = `${key}|${density}`;
  let found = roots.get(id);
  if (found) return found;
  const { pos, nrm, tris } = mesh;
  const rand = random(7919);
  const list: number[] = [];
  for (let t = 0; t < tris.length; t += 3) {
    const a = tris[t] * 3;
    const b = tris[t + 1] * 3;
    const c = tris[t + 2] * 3;
    const ux = pos[b] - pos[a];
    const uy = pos[b + 1] - pos[a + 1];
    const uz = pos[b + 2] - pos[a + 2];
    const vx = pos[c] - pos[a];
    const vy = pos[c + 1] - pos[a + 1];
    const vz = pos[c + 2] - pos[a + 2];
    const area = 0.5 * Math.hypot(uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx);
    let want = area * density;
    while (want > 0) {
      if (want < 1 && rand() > want) break;
      want -= 1;
      let r1 = rand();
      let r2 = rand();
      if (r1 + r2 > 1) {
        r1 = 1 - r1;
        r2 = 1 - r2;
      }
      const w0 = 1 - r1 - r2;
      for (let k = 0; k < 3; k++) list.push(pos[a + k] * w0 + pos[b + k] * r1 + pos[c + k] * r2);
      let nx = nrm[a] * w0 + nrm[b] * r1 + nrm[c] * r2;
      let ny = nrm[a + 1] * w0 + nrm[b + 1] * r1 + nrm[c + 1] * r2;
      let nz = nrm[a + 2] * w0 + nrm[b + 2] * r1 + nrm[c + 2] * r2;
      const l = Math.hypot(nx, ny, nz) || 1;
      nx /= l;
      ny /= l;
      nz /= l;
      list.push(nx, ny, nz, 0.45 + rand() * 0.55, rand());
      list.push(rand() * 2 - 1, rand() * 2 - 1, rand() * 2 - 1);
    }
  }
  // Shuffle, so that drawing only the first part of the list still covers the whole body.
  const count = list.length / STRIDE;
  const data = new Float32Array(list);
  for (let i = count - 1; i > 0; i--) {
    const j = Math.floor(rand() * (i + 1));
    for (let k = 0; k < STRIDE; k++) {
      const s = data[i * STRIDE + k];
      data[i * STRIDE + k] = data[j * STRIDE + k];
      data[j * STRIDE + k] = s;
    }
  }
  found = { data, count };
  if (roots.size > 40) roots.clear();
  roots.set(id, found);
  return found;
}
```

### bot-avatars/shapes.ts

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

/*
 * The eighteen bodies. Each is a flat outline written as a signed distance function (negative
 * inside, in units where the body spans about -1 to 1, y up), which the renderer inflates into a
 * plush pillow: thick in the middle, rounding off to a thin seam at the outline. A body also says
 * where its eyes sit, its own colour, how thick it is, and any hard parts it wears (antennas, ear
 * discs), which are drawn as small glossy solids.
 */

export type ShapeName =
  | 'clover'
  | 'flower'
  | 'star'
  | 'ghost'
  | 'mech'
  | 'circle'
  | 'hexagon'
  | 'square'
  | 'triangle'
  | 'blob'
  | 'drop'
  | 'droid'
  | 'alien'
  | 'cat'
  | 'cloud'
  | 'pill'
  | 'pebble'
  | 'puddle';

export const SHAPE_NAMES: ShapeName[] = [
  'clover',
  'flower',
  'star',
  'ghost',
  'mech',
  'circle',
  'hexagon',
  'square',
  'triangle',
  'blob',
  'drop',
  'droid',
  'alien',
  'cat',
  'cloud',
  'pill',
  'pebble',
  'puddle',
];

type Sdf = (x: number, y: number) => number;

/** A hard part: a stem from `from` to `to` (object units, z out of the face) with a ball on top. */
export type Antenna = {
  from: [number, number, number];
  to: [number, number, number];
  ball: number;
};
/** A flattened disc on the side of the head, facing out along x. */
export type EarDisc = { at: [number, number]; radius: number };

export type Shape = {
  sdf: Sdf;
  /**
   * The body's colour, before light and before the colour tuning (brightness 120 %, saturation
   * 159 % by default) deepens it.
   */
  color: string;
  /** Eyes: their height, the half gap between them, and their radii. */
  eyes: { y: number; gap: number; rx: number; ry: number };
  /** Thickness, relative to the others. */
  depth: number;
  antennas?: Antenna[];
  ears?: EarDisc[];
  /** The colour of hard parts; the body's, a little lighter, without it. */
  partColor?: string;
};

const circle = (x: number, y: number, cx: number, cy: number, r: number) =>
  Math.hypot(x - cx, y - cy) - r;

function box(x: number, y: number, hx: number, hy: number, r: number) {
  const qx = Math.abs(x) - hx + r;
  const qy = Math.abs(y) - hy + r;
  return Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - r;
}

/** A smooth union: the two outlines melt together over about k. */
function smin(a: number, b: number, k: number) {
  const h = Math.max(k - Math.abs(a - b), 0) / k;
  return Math.min(a, b) - h * h * k * 0.25;
}

/** Distance to a closed polygon, any shape, negative inside. */
function polygon(x: number, y: number, v: number[]) {
  const n = v.length / 2;
  let d = (x - v[0]) ** 2 + (y - v[1]) ** 2;
  let s = 1;
  for (let i = 0, j = n - 1; i < n; j = i, i++) {
    const vix = v[i * 2];
    const viy = v[i * 2 + 1];
    const ex = v[j * 2] - vix;
    const ey = v[j * 2 + 1] - viy;
    const wx = x - vix;
    const wy = y - viy;
    const t = Math.min(1, Math.max(0, (wx * ex + wy * ey) / (ex * ex + ey * ey)));
    const bx = wx - ex * t;
    const by = wy - ey * t;
    d = Math.min(d, bx * bx + by * by);
    const c1 = y >= viy;
    const c2 = y < v[j * 2 + 1];
    const c3 = ex * wy > ey * wx;
    if ((c1 && c2 && c3) || (!c1 && !c2 && !c3)) s = -s;
  }
  return s * Math.sqrt(d);
}

function regular(n: number, radius: number, start: number, cy = 0) {
  const out: number[] = [];
  for (let i = 0; i < n; i++) {
    const a = start + (i * Math.PI * 2) / n;
    out.push(Math.cos(a) * radius, Math.sin(a) * radius + cy);
  }
  return out;
}

function starPoints(outer: number, inner: number, cy: number) {
  const out: number[] = [];
  for (let i = 0; i < 10; i++) {
    const a = Math.PI / 2 + (i * Math.PI) / 5;
    const r = i % 2 ? inner : outer;
    out.push(Math.cos(a) * r, Math.sin(a) * r + cy);
  }
  return out;
}

const STAR = starPoints(0.78, 0.43, -0.01);
const HEX = regular(6, 0.78, 0);
const TRIANGLE = [0, 0.66, -0.78, -0.6, 0.78, -0.6];
const DROP_TIP = [0, 0.94, -0.52, -0.02, 0.52, -0.02];
const EAR_L = [-0.8, 0.28, -0.6, 0.98, -0.14, 0.66];
const EAR_R = [0.8, 0.28, 0.14, 0.66, 0.6, 0.98];

const EYES = { y: 0, gap: 0.27, rx: 0.115, ry: 0.15 };

export const SHAPES: Record<ShapeName, Shape> = {
  clover: {
    sdf: (x, y) =>
      smin(
        smin(
          smin(circle(x, y, 0, 0.45, 0.51), circle(x, y, 0, -0.45, 0.51), 0.16),
          smin(circle(x, y, 0.45, 0, 0.51), circle(x, y, -0.45, 0, 0.51), 0.16),
          0.16,
        ),
        circle(x, y, 0, 0, 0.64),
        0.2,
      ),
    color: '#4aaacd',
    eyes: { ...EYES, y: 0 },
    depth: 1,
  },
  flower: {
    sdf: (x, y) => {
      // Drawn a little small, then scaled up as a whole.
      const k = 1.06;
      const px = x / k;
      const py = y / k + 0.03;
      let d = circle(px, py, 0, -0.02, 0.58);
      for (let i = 0; i < 5; i++) {
        const a = Math.PI / 2 + (i * Math.PI * 2) / 5;
        d = smin(d, circle(px, py, Math.cos(a) * 0.44, Math.sin(a) * 0.44 - 0.02, 0.46), 0.12);
      }
      return d * k;
    },
    color: '#46be7c',
    eyes: { ...EYES, y: -0.07, gap: 0.25 },
    depth: 1,
  },
  star: {
    sdf: (x, y) => polygon(x, y, STAR) - 0.15,
    color: '#e0c951',
    eyes: { y: -0.06, gap: 0.22, rx: 0.1, ry: 0.13 },
    depth: 0.95,
  },
  ghost: {
    sdf: (x, y) => {
      const body = Math.min(circle(x, y, 0, 0.04, 0.72), box(x, y + 0.27, 0.72, 0.31, 0.02));
      let bumps = circle(x, y, 0, -0.55, 0.25);
      bumps = Math.min(bumps, circle(x, y, -0.47, -0.55, 0.25), circle(x, y, 0.47, -0.55, 0.25));
      return Math.max(smin(body, bumps, 0.08), Math.abs(x) - 0.72);
    },
    color: '#dad8e0',
    eyes: { y: 0.06, gap: 0.27, rx: 0.12, ry: 0.155 },
    depth: 0.95,
  },
  mech: {
    sdf: (x, y) =>
      smin(
        box(x, y + 0.3, 0.84, 0.6, 0.32),
        Math.min(circle(x, y, 0.8, -0.28, 0.25), circle(x, y, -0.8, -0.28, 0.25)),
        0.2,
      ),
    color: '#7586ab',
    eyes: { y: -0.32, gap: 0.26, rx: 0.115, ry: 0.15 },
    depth: 0.9,
    antennas: [
      { from: [-0.42, 0.22, 0], to: [-0.6, 0.86, 0], ball: 0.105 },
      { from: [0.42, 0.22, 0], to: [0.6, 0.86, 0], ball: 0.105 },
    ],
  },
  circle: {
    sdf: (x, y) => circle(x, y, 0, 0, 0.945),
    color: '#7b5bbd',
    eyes: { ...EYES, gap: 0.25 },
    depth: 1,
  },
  hexagon: {
    sdf: (x, y) => polygon(x, y, HEX) - 0.17,
    color: '#bb5f5f',
    eyes: { ...EYES, gap: 0.26 },
    depth: 1,
  },
  square: {
    sdf: (x, y) => box(x, y, 0.95, 0.95, 0.36),
    color: '#539cc9',
    eyes: { ...EYES, gap: 0.27 },
    depth: 1,
  },
  triangle: {
    sdf: (x, y) => polygon(x, y, TRIANGLE) - 0.2,
    color: '#d99153',
    eyes: { ...EYES, y: -0.2, gap: 0.22, rx: 0.1, ry: 0.13 },
    depth: 0.95,
  },
  blob: {
    sdf: (x, y) =>
      smin(
        smin(circle(x, y, -0.3, 0.12, 0.6), circle(x, y, 0.36, 0.2, 0.52), 0.3),
        smin(circle(x, y, 0.08, -0.34, 0.58), circle(x, y, 0.56, -0.3, 0.34), 0.3),
        0.3,
      ),
    color: '#50bdb1',
    eyes: { ...EYES, y: 0.02, gap: 0.25 },
    depth: 1,
  },
  drop: {
    sdf: (x, y) => smin(circle(x, y, 0, -0.26, 0.66), polygon(x, y, DROP_TIP) - 0.04, 0.3),
    color: '#5891d2',
    eyes: { ...EYES, y: -0.26, gap: 0.24 },
    depth: 1,
  },
  droid: {
    sdf: (x, y) => box(x, y + 0.04, 0.7, 0.8, 0.46),
    color: '#b5bac5',
    eyes: { ...EYES, y: 0.02, gap: 0.25 },
    depth: 0.95,
    antennas: [{ from: [0, 0.68, 0], to: [0, 1.1, 0], ball: 0.1 }],
    ears: [
      { at: [-0.7, 0], radius: 0.2 },
      { at: [0.7, 0], radius: 0.2 },
    ],
    partColor: '#676d7d',
  },
  alien: {
    sdf: (x, y) => smin(circle(x, y, 0, 0.14, 0.8), circle(x, y, 0, -0.56, 0.34), 0.55),
    color: '#97cf6b',
    eyes: { y: 0.04, gap: 0.3, rx: 0.15, ry: 0.19 },
    depth: 1,
    antennas: [
      { from: [-0.28, 0.72, 0], to: [-0.58, 1.12, 0], ball: 0.09 },
      { from: [0.28, 0.72, 0], to: [0.58, 1.12, 0], ball: 0.09 },
    ],
  },
  cat: {
    sdf: (x, y) =>
      smin(
        circle(x, y, 0, -0.08, 0.84),
        Math.min(polygon(x, y, EAR_L) - 0.07, polygon(x, y, EAR_R) - 0.07),
        0.12,
      ),
    color: '#d99a62',
    eyes: { ...EYES, y: -0.06, gap: 0.27 },
    depth: 1,
  },
  cloud: {
    sdf: (x, y) => {
      let d = circle(x, y, 0, 0.14, 0.56);
      d = smin(d, circle(x, y, -0.56, -0.16, 0.42), 0.16);
      d = smin(d, circle(x, y, 0.56, -0.14, 0.44), 0.16);
      d = smin(d, circle(x, y, -0.2, -0.3, 0.42), 0.16);
      d = smin(d, circle(x, y, 0.24, -0.3, 0.42), 0.16);
      return Math.max(d, -y - 0.62);
    },
    color: '#e2e6f0',
    eyes: { ...EYES, y: -0.06, gap: 0.25 },
    depth: 0.95,
  },
  pill: {
    sdf: (x, y) => box(x, y, 0.95, 0.56, 0.56),
    color: '#c96b8e',
    eyes: { ...EYES, gap: 0.26 },
    depth: 0.95,
  },
  pebble: {
    sdf: (x, y) => {
      const a = Math.atan2(y, x);
      const r = Math.hypot(x / 0.95, y / 0.74);
      return (r - 1 - 0.035 * Math.sin(a * 3 + 0.6) - 0.025 * Math.cos(a * 2)) * 0.74;
    },
    color: '#a2968d',
    eyes: { ...EYES, y: 0.02, gap: 0.26 },
    depth: 0.85,
  },
  puddle: {
    sdf: (x, y) => {
      let d = box(x, y + 0.2, 0.95, 0.36, 0.36);
      d = smin(d, circle(x, y, -0.22, 0.08, 0.42), 0.2);
      d = smin(d, circle(x, y, 0.32, 0.04, 0.34), 0.2);
      return d;
    },
    color: '#67acd1',
    eyes: { ...EYES, y: -0.06, gap: 0.25, rx: 0.1, ry: 0.13 },
    depth: 0.7,
  },
};
```

### bot-avatars/bot-avatars.module.css

```css
/* Bot avatars (bot-avatars.tsx and bot-avatar.tsx): near-black cards at night, pale grey ones by
   day, each with its scene: a row of avatars, a list of bots, a chat, then the playground (a stage
   and a panel of controls) and the code for what it shows.

   Colours are the tokens below, light-dark(light, dark) where the two differ: the showcase 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 avatars are drawn the same on both.

   Memory. Each avatar's canvas is its own layer, half as large again as its box, and only redraws
   while on screen; the cards are fixed sizes, so a redraw never lays out the page. */

.root {
  /* Cards, the playground's panel, and the list's sheet. */
  --card: #f3f3f4;
  --panel: #ffffff;
  --sheet: #ffffff;
  --ring: rgb(0 0 0 / 0.07);
  --lift: rgb(0 0 0 / 0.06);
  /* Words: strong, captions, quiet, field labels, the list's heading, the bot's reply. */
  --ink: #171717;
  --caption: #8b8b8b;
  --quiet: rgb(60 60 66 / 0.68);
  --label: #7b7b7b;
  --head: #a6a6a6;
  --reply: #4b4b4f;
  /* Chips, sliders and the question's bubble: resting and chosen. */
  --chip: rgb(0 0 0 / 0.04);
  --chip-on: #ffffff;
  --chip-ink: #6e6e6e;
  --chip-ink-on: #171717;
  --chip-edge: rgb(0 0 0 / 0.06);
  --sep: rgb(0 0 0 / 0.06);
  --tip: #1f1f1f;
  --tip-ink: #f2f2f2;
  --focus: #387bff;
  display: grid;
  gap: 12px;
  width: 100%;
  max-width: 936px;
  margin-inline: auto;
  container-type: inline-size;
  color: var(--ink);
  font-family: inherit;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --card: light-dark(#f3f3f4, #171717);
    --panel: light-dark(#ffffff, #1b1b1b);
    --sheet: light-dark(#ffffff, #1d1d1d);
    --ring: light-dark(rgb(0 0 0 / 0.07), rgb(44 47 54 / 0.52));
    --lift: light-dark(rgb(0 0 0 / 0.06), rgb(0 0 0 / 0.24));
    --ink: light-dark(#171717, #fbfbfb);
    --caption: light-dark(#8b8b8b, #767676);
    --quiet: light-dark(rgb(60 60 66 / 0.68), rgb(202 202 202 / 0.7));
    --label: light-dark(#7b7b7b, #979797);
    --head: light-dark(#a6a6a6, #4e4e4e);
    --reply: light-dark(#4b4b4f, #c0c0c0);
    --chip: light-dark(rgb(0 0 0 / 0.04), rgb(255 255 255 / 0.04));
    --chip-on: light-dark(#ffffff, rgb(255 255 255 / 0.08));
    --chip-ink: light-dark(#6e6e6e, #b1b1b1);
    --chip-ink-on: light-dark(#171717, #fbfbfb);
    --chip-edge: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.04));
    --sep: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.04));
    --tip: light-dark(#1f1f1f, #181818);
    --tip-ink: light-dark(#f2f2f2, #e3e3e3);
  }
}
.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;
}

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

/* One avatar: its box in the layout, and the canvas half as large again around it. */
.avatar {
  position: relative;
  display: inline-block;
  flex: none;
  width: var(--size);
  height: var(--size);
  touch-action: manipulation;
}
.canvas {
  position: absolute;
  /* More room above than below, for hops and hats. */
  top: calc(var(--size) * -0.354);
  left: calc(var(--size) * -0.25);
  width: calc(var(--size) * 1.5);
  height: calc(var(--size) * 1.5);
  pointer-events: none;
}

/* The row of four. */
.preview {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  place-items: center;
  min-height: 280px;
  padding: 40px 32px;
  border-radius: 16px;
  background: var(--card);
}
.team {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 30px 18px;
}
.seat {
  display: grid;
  justify-items: center;
  gap: 15px;
  width: 126px;
  margin: 0;
}
.caption {
  font-size: 12px;
  line-height: 14px;
  color: var(--caption);
}

/* The two scenes. */
.split {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
}
.cell {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  place-items: center;
  min-height: 314px;
  padding: 32px 28px;
  border-radius: 16px;
  background: var(--card);
  min-width: 0;
}

.bots {
  width: 300px;
  max-width: 100%;
  padding: 8px;
  border-radius: 16px;
  background: var(--sheet);
  box-shadow: inset 0 0 0 1px var(--ring);
}
.botsHead {
  margin: 0;
  padding: 6px 10px 8px;
  font-size: 12px;
  font-weight: 500;
  line-height: 16px;
  color: var(--head);
}
.botsList {
  margin: 0;
  padding: 0;
  list-style: none;
}
.botRow {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 8px 8px 8px 10px;
}
.botText {
  display: grid;
  gap: 4px;
  min-width: 0;
}
.botName {
  font-size: 13px;
  font-weight: 500;
  line-height: 16px;
}
.botStatus {
  font-size: 12px;
  line-height: 15px;
  color: var(--quiet);
}

.thread {
  display: grid;
  gap: 16px;
  width: 340px;
  max-width: 100%;
}
.bubble {
  justify-self: end;
  max-width: 280px;
  margin: 0;
  padding: 9px 13px;
  border-radius: 16px 16px 4px 16px;
  background: var(--chip);
  box-shadow: inset 0 0 0 1px var(--ring);
  font-size: 13px;
  line-height: 18px;
}
.botLine {
  display: flex;
  align-items: flex-start;
  gap: 10px;
}
.reply {
  display: grid;
  flex: 1;
  min-width: 0;
  padding-top: 1px;
}
.replyGhost,
.replyLive {
  grid-area: 1 / 1;
  margin: 0;
  font-size: 13px;
  line-height: 18px;
  color: var(--reply);
}
.replyGhost {
  visibility: hidden;
}
.word {
  animation: wordIn 420ms ease-out both;
}
@keyframes wordIn {
  from {
    opacity: 0.15;
  }
  to {
    opacity: 1;
  }
}
/* Thinking: a band of light sweeps through the words. */
.thinking {
  background:
    linear-gradient(100deg, transparent 0 35%, var(--ink) 45% 55%, transparent 65% 100%) 0 0 / 300%
      100% no-repeat,
    var(--quiet);
  background-clip: text;
  color: transparent;
  animation: sweep 1.6s linear infinite;
}
@keyframes sweep {
  from {
    background-position:
      100% 0,
      0 0;
  }
  to {
    background-position:
      0% 0,
      0 0;
  }
}

/* The playground. */
.playground {
  display: grid;
  gap: 12px;
  margin-top: 12px;
}
.pgTitle {
  margin: 0;
  font-size: 13px;
  font-weight: 500;
  line-height: 14px;
}
.pg {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 244px;
  gap: 12px;
  height: 880px;
}
.stage {
  position: relative;
  display: grid;
  place-items: center;
  border-radius: 16px;
  background: var(--card);
  min-width: 0;
}
.play {
  position: absolute;
  bottom: 20px;
  left: 50%;
  translate: -50% 0;
  height: 32px;
  padding: 0 12px;
  border: 0;
  border-radius: 40px;
  background: var(--chip);
  color: var(--chip-ink-on);
  font: inherit;
  font-size: 13px;
  font-weight: 500;
  line-height: 16px;
  cursor: pointer;
  transition: background-color 160ms ease;
}
.play:hover {
  background: var(--chip-on);
}

.controls {
  display: flex;
  flex-direction: column;
  gap: 16px;
  padding: 16px;
  overflow-y: auto;
  overscroll-behavior: contain;
  border-radius: 16px;
  background: var(--panel);
  box-shadow:
    0 2px 6px rgb(0 0 0 / 0.05),
    0 16px 32px -12px var(--lift),
    inset 0 0 0 1px var(--chip-edge);
  scrollbar-width: thin;
}
.field {
  display: grid;
  gap: 8px;
}
.fieldLabel {
  font-size: 13px;
  line-height: 14px;
  color: var(--label);
}
.chips {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
}
.chip {
  height: 32px;
  padding: 0 12px;
  border: 0;
  border-radius: 36px;
  background: var(--chip);
  color: var(--chip-ink);
  font: inherit;
  font-size: 13px;
  font-weight: 500;
  line-height: 16px;
  cursor: pointer;
  transition:
    background-color 160ms ease,
    color 160ms ease,
    box-shadow 160ms ease;
}
.chip:hover {
  color: var(--chip-ink-on);
}
.chip[aria-checked='true'] {
  background: var(--chip-on);
  color: var(--chip-ink-on);
  box-shadow:
    0 1px 1px var(--lift),
    inset 0 0 0 1px var(--chip-edge);
}

.tuning {
  display: grid;
  gap: 14px;
}
.tuning > .field + .field {
  padding-top: 14px;
  border-top: 1px solid var(--sep);
}
.slider {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 32px;
  overflow: hidden;
  border-radius: 8px;
  background: var(--chip);
  cursor: ew-resize;
}
.sliderFill {
  position: absolute;
  inset: 0 auto 0 0;
  border-radius: 8px;
  background: var(--chip-on);
  box-shadow:
    0 1px 1px var(--lift),
    inset 0 0 0 1px var(--chip-edge);
}
.sliderName,
.sliderValue {
  position: relative;
  font-size: 13px;
  font-weight: 500;
  line-height: 14px;
  color: var(--chip-ink-on);
}
.sliderName {
  padding-left: 12px;
}
.sliderValue {
  padding-right: 12px;
  font-variant-numeric: tabular-nums;
}
.range {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  opacity: 0;
  cursor: ew-resize;
}
.slider:has(.range:focus-visible) {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.swatches {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
.swatch {
  width: 26px;
  height: 26px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  box-shadow: inset 0 0 0 1px rgb(0 0 0 / 0.12);
  cursor: pointer;
  transition: box-shadow 160ms ease;
}
.swatch[aria-pressed='true'] {
  box-shadow:
    inset 0 0 0 1px rgb(0 0 0 / 0.12),
    0 0 0 2px var(--panel),
    0 0 0 3.5px var(--chip-ink-on);
}

.code {
  position: relative;
  border-radius: 16px;
  background: var(--card);
}
.pre {
  margin: 0;
  padding: 24px 56px 24px 26px;
  overflow-x: auto;
  font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
  font-size: 12.5px;
  line-height: 21px;
  white-space: pre-wrap;
  word-break: break-word;
}
.copy {
  position: absolute;
  top: 14px;
  right: 14px;
  display: grid;
  place-items: center;
  width: 32px;
  height: 32px;
  padding: 0;
  border: 0;
  border-radius: 9px;
  background: transparent;
  color: var(--quiet);
  cursor: pointer;
  transition:
    background-color 160ms ease,
    color 160ms ease;
}
.copy:hover {
  background: var(--chip);
  color: var(--ink);
}
.copyIcon {
  width: 15px;
  height: 15px;
}
.tooltip {
  position: absolute;
  right: 0;
  bottom: calc(100% + 8px);
  padding: 8px 12px;
  border-radius: 40px;
  background: var(--tip);
  color: var(--tip-ink);
  font-size: 12px;
  line-height: 16px;
  white-space: nowrap;
  box-shadow: 0 1px 16px rgb(0 0 0 / 0.4);
  opacity: 0;
  translate: 0 4px;
  pointer-events: none;
  transition:
    opacity 160ms ease,
    translate 160ms ease;
}
.copy:hover .tooltip,
.copy:focus-visible .tooltip {
  opacity: 1;
  translate: 0 0;
}

.chip:focus-visible,
.play:focus-visible,
.swatch:focus-visible,
.copy:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* Narrow: the scenes stack, and the panel goes under the stage at its full height. */
@container (max-width: 720px) {
  .split {
    grid-template-columns: 1fr;
  }
  .pg {
    grid-template-columns: 1fr;
    height: auto;
  }
  .stage {
    height: 420px;
  }
  .controls {
    overflow: visible;
  }
}
@container (max-width: 420px) {
  .preview {
    padding: 32px 12px;
  }
  .seat {
    width: 112px;
  }
  .cell {
    padding: 24px 12px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .word,
  .thinking {
    animation: none;
  }
  .thinking {
    background: none;
    color: var(--quiet);
  }
}
```

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