Maus: morphing face engine on the existing lit body - #31
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
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 0the 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, soturn 0means forward andturnmeasures from there. Two details:curiousstays wide,drowsyflat,suspiciousa squintOpt 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:
faceScaleeyeScaleSo the face cannot get bigger — 0.47 was already near the ceiling — but it can get bolder, hence
eyeScale1.12 andmouthStroke11, 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_PATHis byte-identical to the app'sBODY, so only the frames differ.FACE_TO_BODYinverts the engine's fit and reapplies the rotation the body already carries, derived fromMAUS_FIT_SCALE/OFFSETrather than eyeballed.It has to be expressed in pre-rotation space, which is why the face group nests inside
.maus-solid. That group is styledtransform-box: view-box; transform-origin: 100px 100pxwhile also carrying atransformattribute — and browsers treat the SVG transform attribute as the CSStransformproperty, so the origin applies on top of the attribute's own rotate-about-point. The body is really drawn attranslate(-28.17 40.23)of where the attribute alone puts it, and the component'sviewBox="-22 51 164 164"is framed around that. Nesting the face under the same group inherits the quirk instead of fighting it. Predicted CTMe=-62.81, f=53.89vs measured-62.82, 53.9.I deliberately did not unpick this — it would move the mascot in every avatar.
Performance
One
requestAnimationFramedrives 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
working, tool failure →alerting, unread →notifying. Bots saved under the old names are translated on read bynormalizeState(), so a downgrade survives too.happy/excited/playfulall 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.switch, tool success →success, approval →alert…); the grid was the sole dispatcher ofpreviewMascotMotion, so that action went with it.mascot-preview.htmlis a dev-only tuning harness — live geometry sliders, every state and motion, all 10 colours. Vite builds onlyindex.html, so it doesn't ship.STATE_GROUPSshipped with French keys; translated, since they're picker labels.Testing
tsc -band the server typecheck both clean🤖 Generated with Claude Code