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 |
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).
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:
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.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.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.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.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.
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.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.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 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.
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 panels are built from the primitives in components/primitives.tsx. All four
input controls are drawn from the tokens; none shows a platform skin.
EyeDropper button appears on
Chromium.<input type="range"> with a custom track, fill and thumb.role="switch", drawn as a track and knob.Both popovers close on Escape and outside clicks via components/dismiss.ts.
| 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.