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.
| 1 | bun install |
| 2 | bun run dev # the lab |
| 3 | bun run check # lint, typecheck, tests |
| 4 | 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:
- 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. - Silhouette (
engine/shape.ts,engine/outline.ts) — a closed path. Most shapes state a CSSborder-radiusand 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. - 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. - Eyes (
engine/face.ts) — each eye's centroid becomes a longitude on the sphere viaasin, the head turn is added, and the new longitude places the eye and compresses its width withcos. 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.StateNameis derived fromSTATE_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
EyeDropperbutton 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
| 1 | src/ |
| 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.