81966dac0d
The companion rerender storm (~46 renders/2s from RAF loops) was causing
the animated SVG subtree to be replaced on every render, killing SMIL
and CSS animations (dizzy spirals, sleepy Zzz, etc.).
Three root causes fixed:
1. Ref-based gaze: eyeOffset was React state updated every frame in
useBlobbiCompanionGaze, propagating rerenders through the entire
companion tree. Now writes to a ref that useExternalEyeOffset reads
imperatively via its own RAF loop — zero React rerenders for gaze.
2. Memoized SVG renderer: created MemoizedBlobbiVisual (React.memo)
that only rerenders when visual content changes (blobbi, recipe,
emotion, bodyEffects). Reaction CSS classes (sway/bounce) moved to
an outer wrapper div in BlobbiCompanionVisual so className changes
don't touch the dangerouslySetInnerHTML container.
3. Stable recipe references: resolveStatusRecipe() returned fresh {}
objects for neutral state, defeating memo comparators. Now uses
shared frozen EMPTY_RECIPE and NEUTRAL_STATUS_RESULT constants.