Skip to content

Maus: morphing face engine on the existing lit body - #31

Merged
milind-soni merged 1 commit into
milind-soni:mainfrom
aivsomkar:feature/maus-face-engine
Aug 12, 2026
Merged

Maus: morphing face engine on the existing lit body#31
milind-soni merged 1 commit into
milind-soni:mainfrom
aivsomkar:feature/maus-face-engine

Conversation

@aivsomkar

@aivsomkar aivsomkar commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

The mascot's face was ten hand-drawn SVG blocks with no motion of their own — everything moved via the CSS motion library around them. This swaps the face for the GrokBot morph engine while leaving that body and library untouched.

  • 25 expressions as 48-point rings per eye, spring-interpolated, so a grin melts into a frown
  • 39 named states, each with an expression pool, drift cadence and blink rhythm
  • gaze, and a spherical turn where only the face travels — the silhouette never rotates

Faces now look at you

Each of the 25 expressions was authored at its own longitude on the implied sphere, from −25.6° to +48.9°. So at turn 0 the maus never actually faced the viewer — it faced wherever its current expression happened to be drawn. idle, the default, rests at +27.6°: a permanent glance to the right.

centreOffset() cancels the eye pair's longitude, so turn 0 means forward and turn measures from there. Two details:

  • it reads the live morphing rings, so the face stays forward through an expression change instead of swinging as each new one lands
  • only the pair is re-centred — each eye keeps its authored position, so curious stays wide, drowsy flat, suspicious a squint

Opt out with forward={false}.

Geometry was measured, not guessed

Every one of the 25 expressions was hit-tested against the silhouette to find where the face actually clips:

authored gaze facing forward
max faceScale 0.4937 0.4937
max eyeScale 1.336 1.812
max pointer gaze 0.258 1.0 (full range)

So the face cannot get bigger — 0.47 was already near the ceiling — but it can get bolder, hence eyeScale 1.12 and mouthStroke 11, both clear in either mode. Centring also pulls the eyes toward the fat middle of the silhouette, which is why it buys back the gaze range.

The coordinate bridge

MAUS_PATH is byte-identical to the app's BODY, so only the frames differ. FACE_TO_BODY inverts the engine's fit and reapplies the rotation the body already carries, derived from MAUS_FIT_SCALE/OFFSET rather than eyeballed.

It has to be expressed in pre-rotation space, which is why the face group nests inside .maus-solid. That group is styled transform-box: view-box; transform-origin: 100px 100px while also carrying a transform attribute — and browsers treat the SVG transform attribute as the CSS transform property, so the origin applies on top of the attribute's own rotate-about-point. The body is really drawn at translate(-28.17 40.23) of where the attribute alone puts it, and the component's viewBox="-22 51 164 164" is framed around that. Nesting the face under the same group inherits the quirk instead of fighting it. Predicted CTM e=-62.81, f=53.89 vs measured -62.82, 53.9.

I deliberately did not unpick this — it would move the mascot in every avatar.

Performance

One requestAnimationFrame drives every mounted avatar rather than one per instance, and a settled face is not redrawn. 163 avatars held 60fps (16.7 ms median, 17.7 ms worst), so a resting bot list costs nothing.

Also in here

  • States replace the ten expression names. Live bot state routes to states the old set couldn't express: busy → working, tool failure → alerting, unread → notifying. Bots saved under the old names are translated on read by normalizeState(), so a downgrade survives too.
  • The appearance picker had 15 buttons showing 8 pictures. States share resting faces (happy/excited/playful all rest on expression 2) and differ only in what they drift to, which a static swatch can't show. Now one state per distinct face.
  • Motion preview grid removed. Motions already have 13 real triggers in the reducer (select → switch, tool success → success, approval → alert…); the grid was the sole dispatcher of previewMascotMotion, so that action went with it.
  • mascot-preview.html is a dev-only tuning harness — live geometry sliders, every state and motion, all 10 colours. Vite builds only index.html, so it doesn't ship.
  • Engine STATE_GROUPS shipped with French keys; translated, since they're picker labels.

Testing

  • tsc -b and the server typecheck both clean
  • 82/82 vitest tests pass
  • clipping re-verified at the shipping values across all 25 expressions: zero clips
  • checked in the real app (sidebar, chat header, settings, onboarding) and in the harness

🤖 Generated with Claude Code

The mascot's face was ten hand-drawn SVG blocks with no motion of their
own — everything moved via the CSS motion library around them. This swaps
the face for the GrokBot morph engine while keeping that body and library
untouched: 25 expressions as 48-point rings per eye, spring-interpolated,
39 named states each with an expression pool, drift cadence and blink
rhythm, plus gaze and a spherical turn.

The silhouette is identical on both sides (MAUS_PATH is the app's BODY),
so only the coordinate frames differ. FACE_TO_BODY inverts the engine's
fit and reapplies the rotation the body already carries, derived from
MAUS_FIT_SCALE/OFFSET rather than eyeballed. It has to be expressed in
pre-rotation space, so the face group nests inside .maus-solid: that
group is styled `transform-box: view-box; transform-origin: 100px 100px`
while also carrying a transform attribute, and browsers apply the CSS
origin on top of the attribute's own rotate-about-point. The body is
really drawn at translate(-28.17 40.23) of where the attribute alone puts
it, which is what the viewBox is framed around. Nesting the face under
the same group inherits that instead of fighting it.

Faces now look at you. Each of the 25 expressions was authored at its own
longitude on the implied sphere (-25.6° to +48.9°), so at turn 0 the maus
faced wherever its current expression happened to be drawn — `idle` rests
at +27.6°, a permanent glance right. centreOffset() cancels the pair's
longitude so turn 0 means forward and `turn` measures from there. It
reads the live morphing rings, so the face stays forward through an
expression change rather than swinging as each new one lands, and only
the pair is re-centred — each eye keeps its authored position, so the
expressions stay distinct. Off via `forward={false}`.

Geometry was measured, not guessed, by hit-testing all 25 expressions
against the silhouette: faceScale clips past 0.4937 (so 0.47 stays),
eyeScale past 1.336 and mouthStroke past 22.7 with authored gaze, rising
to 1.812 and full gaze once centred. The face therefore cannot get
bigger, only bolder — hence eyeScale 1.12 and mouthStroke 11, both clear
in either mode.

One rAF drives every avatar instead of one per instance, and a settled
face is not redrawn; 163 avatars hold 60fps.

Also:
- states replace the ten expression names, with live bot state routed to
  states the old set could not express (busy -> working, tool failure ->
  alerting, unread -> notifying). Bots saved under the old names are
  translated on read by normalizeState(), so a downgrade survives.
- the appearance picker offered 15 states showing 8 pictures, since
  states share resting faces and differ only in what they drift to. It
  now lists one state per distinct face.
- the motion preview grid is gone: motions already have 13 real triggers
  in the reducer, and it was the sole dispatcher of previewMascotMotion.
- mascot-preview.html is a dev-only tuning harness (Vite builds only
  index.html) with live geometry sliders and every state and motion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@milind-soni
milind-soni merged commit b09439b into milind-soni:main Aug 12, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants