# README.md · versecafe/bot-face-labs

[View on GitCafe](https://git.cafe/versecafe/bot-face-labs/blob/HEAD/README.md)

Repository: [versecafe/bot-face-labs](https://git.cafe/versecafe/bot-face-labs)

Visibility: public

Requested revision: HEAD

Commit: a1a9850e27c0abf759524162fb1a08702c0bd27a

Blob: b29dadf21cec9bf69c2789977f819bf137e1ddbd

Size: 5523 bytes

[Immutable source](https://git.cafe/versecafe/bot-face-labs/blob/a1a9850e27c0abf759524162fb1a08702c0bd27a/README.md?format=markdown)

````
# Bot Face Lab

A workbench for one procedural face: pick a state, an expression and a
silhouette, then watch — and tune — every layer that turns those three choices
into a frame. Solid 2, StyleX, Vite, no runtime dependencies beyond those.

```sh
bun install
bun run dev      # the lab
bun run check    # lint, typecheck, tests
bun run build    # typecheck + production bundle
```

## Face space

Everything geometric is expressed in one coordinate system: a `228.541`-unit box
whose centre is `114.2705`, with the head modelled as a sphere of radius `105`
sitting in it. Eye outlines, silhouette paths, gaze offsets and guides are all in
those units, so any two of them can be composed without conversion. The only
places that leave face space are the `viewBox` (which pads it by 15 units so an
eye can swing past the limb) and the favicon (which stamps a pixel size on it).

## The layers of a frame

A frame is built outside-in, and both render surfaces — the on-page avatar and
the tab favicon — compose the same four layers in the same order:

1. **Head transform** (`engine/shape.ts`) — the mirror, then the silhouette's
   lean. Body, guides, eyes and centroid markers all ride this one string, which
   is what keeps them from separating under a turn.
2. **Silhouette** (`engine/shape.ts`, `engine/outline.ts`) — a closed path. Most
   shapes state a CSS `border-radius` and are parsed into a path here, so the
   catalogue swatch and the head are drawn from the identical string. The ones a
   radius cannot describe — hexagon, triangle, drop, cloud — are built by the
   outline functions instead.
3. **Face correction** (`engine/shape.ts`) — each shape nudges and squashes the
   features to sit inside a silhouette that is rarely as wide or as tall as the
   default blob.
4. **Eyes** (`engine/face.ts`) — each eye's centroid becomes a longitude on the
   sphere via `asin`, the head turn is added, and the new longitude places the
   eye and compresses its width with `cos`. Past the limb it stops being drawn.

## What drives it

`engine/controller.ts` owns the frame loop, the expression spring, the automatic
blink and expression timers, and the composition of user input with the active
state's secondary motion. It is the only stateful piece; everything it calls is
pure and separately testable.

- **States** (`data/states.ts`) carry an expression pool, two cadences and a note.
  `StateName` is derived from `STATE_GROUPS`, so every table keyed by a state is
  checked for exhaustiveness at compile time.
- **Secondary motion** (`engine/motion.ts`) is what makes states differ by more
  than cadence: each is a set of sine channels — sweep, bob, lean, breathe,
  jitter — plus a resting gaze bias and eye openness.
- **Expressions** (`data/expressions.ts`) are 25 pairs of 48-point rings, in
  matching order, so blending two of them is a straight per-point lerp.

The loop parks itself when nothing is animating and wakes on any mutation, so a
resting face costs nothing. `prefers-reduced-motion` suppresses the secondary
motion, the timers and the spring, with an in-app override.

## The favicon experiment

The tab icon is a second presentation surface for the same renderer and the same
state machine, not a parallel implementation: `renderFaviconFrame()` builds one
still SVG frame, exaggerated so it survives being drawn at 32 pixels, and the
driver in `components/FaviconAnimator.tsx` pushes frames on its own timer.
Identical frames are never written and the loop parks while the tab is hidden.
Chromium and Firefox animate the tab; Safari draws the first frame and ignores
the rest. There is no browser-specific code — the loop simply has no visible
effect there.

## Sharing a configuration

Every control, plus the state, shape and expression, round-trips through the URL
fragment (`engine/share.ts`); only values that differ from the defaults are
written. **Copy link** puts that URL on the clipboard and in the address bar,
and **Copy SVG** copies the current frame as a standalone file.

## The controls

The panels are built from the primitives in `components/primitives.tsx`. All four
input controls are drawn from the tokens; none shows a platform skin.

- **Select** — a listbox: trigger button plus a panel of grouped options.
  Arrows, PageUp/PageDown, Home/End, typeahead, Enter or Tab to commit, Escape
  to cancel, outside click to dismiss.
- **Colour** — an in-page picker: saturation/value plane, hue rail, preview, and
  the hex field beside the swatch. Held as HSV. An `EyeDropper` button appears on
  Chromium.
- **Slider** — `<input type="range">` with a custom track, fill and thumb.
- **Switch** — a checkbox with `role="switch"`, drawn as a track and knob.

Both popovers close on Escape and outside clicks via `components/dismiss.ts`.

## Layout

```
src/
  data/        the catalogues: expressions, shapes, states
  engine/      DOM-free logic, plus the one stateful controller
  components/  the avatar, the preview rail, the control primitives, and one
               file per control panel
  styles/      StyleX: tokens, then one stylesheet per concern
```

Tests live beside what they test (`*.test.ts`) and run under `bun test`. They
cover the geometry, the spring and blink curves, the colour and share parsers
(including the picker's hex/HSV round-trip),
the catalogues' internal consistency, and the controller itself — the last of
these drives the real animation loop against a hand-cranked clock, so it needs
no browser.

````
