README.md

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
1bun install
2bun run dev # the lab
3bun run check # lint, typecheck, tests
4bun 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

1src/
2 data/ the catalogues: expressions, shapes, states
3 engine/ DOM-free logic, plus the one stateful controller
4 components/ the avatar, the preview rail, the control primitives, and one
5 file per control panel
6 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.