Changes:
- Store blink center as data-cx/data-cy attributes on .blobbi-blink groups
- Use eye white center (cx, cy) as blink anchor when available
- Fallback to pupil center if no eye white found
- Read center from data attributes in animation loop (more reliable than CSS)
Structure per eye:
<g class="blobbi-blink" data-cx="38" data-cy="45"> <!-- blink group -->
<ellipse ... /> <!-- eye white -->
<g class="blobbi-eye"> <!-- tracking group -->
<circle ... /> <!-- pupil -->
<circle ... /> <!-- highlight -->
</g>
</g>
Blink transform: translate(cx,cy) scale(1,blinkY) translate(-cx,-cy)
- Scales around the actual eye center from SVG element data
- No CSS transform-origin parsing needed
- Eye closes in place without shifting
Problem: scale(1 blinkY) was scaling from top-left origin, causing
the eye to move down during blink.
Solution: Use translate-scale-translate pattern to scale around the
eye's center point:
translate(cx cy) scale(1 blinkY) translate(-cx -cy)
The center coordinates are extracted from the transform-origin style
that was already set during SVG processing.
Tracking behavior remains completely unchanged - only the blink
transform application was modified.
The wrapRef div had no dimensions, so max-w-full/max-h-full on the img
resolved against the image's own natural size rather than the available
space. Give wrapRef full width/height so percentage constraints work
correctly against the padded slot area.
- Random blink intervals between 2-5 seconds for organic feel
- Blink animation: fast close (~80ms), pause (~100ms), slower open (~120ms)
- 20% chance for double blinks (extra polish)
- Uses scaleY transform combined with mouse tracking translate
- Easing functions: ease-in for close, ease-out for open
- Disabled when Blobbi is sleeping
- No CSS transitions - all animation via RAF for instant response
- Remove CSS transitions from .blobbi-eye class (root cause of delay)
- Use SVG transform attribute instead of style.transform for reliable repaints
- Cache eye element references after mount with automatic refresh on SVG changes
- Hook now manages DOM directly without onUpdate callback
- Simplified visual components to just pass containerRef
Browsers (especially Safari/Firefox) require pushManager.subscribe() to be
synchronously reachable from a user gesture. The previous code awaited a
getVapidKey() network call inside enable(), which broke the activation chain
and threw 'DOMException: The operation is insecure'.
Fix: fetch and cache the VAPID key during SW registration on mount so it is
ready in vapidKeyRef before the user ever clicks Enable, making
pushManager.subscribe() the first async call after the user gesture.
Removed from previous system:
- Idle random movement logic
- Energy-based behavior (timing, smoothing, micro-movements)
- Tracking radius (200px distance check)
- Idle/tracking state switching
- lerp interpolation for tracking
- isTracking state and callback parameter
- Per-instance mouse listeners
New behavior:
- Eyes ALWAYS follow the mouse cursor
- Works across the entire screen (no distance limit)
- Instant response (no interpolation, no lag)
- Simple angle calculation every frame
How the new tracking loop works:
1. Global mouse listener updates globalMouseX/globalMouseY
2. RAF loop runs every frame
3. Calculate angle: atan2(mouseY - centerY, mouseX - centerX)
4. Calculate position: cos(angle) * max, sin(angle) * max * 0.7
5. Call onUpdate callback with position
6. DOM updated directly (no React state)
Performance optimizations:
- Single global mouse listener shared by all Blobbi instances
- Instance count tracking for cleanup
- No React state in animation loop
- Direct DOM manipulation via callback
- Minimal computation per frame (just trig)
Hook reduced from ~390 lines to ~140 lines.
Problems fixed:
1. React state caused tracking delay
- setState() batches updates and triggers re-renders
- Even with refs, calling setState inside RAF caused 1-2 frame lag
- Eyes only followed mouse properly when it stopped moving
2. Tracking intensity was inverted
- Old: farther mouse = stronger movement (wrong)
- New: closer mouse = stronger movement (correct)
- Formula: intensity = 1 - Math.pow(normalizedDistance, 0.5)
3. Idle froze after mouse left
- Old: scheduled idle change 300-800ms later
- New: immediately triggers new idle target when tracking stops
- Formula: nextIdleChangeRef.current = currentTime (force immediate)
Solution - callback-based architecture:
- REMOVED all setState calls from animation loop
- Added onUpdate(left, right, isTracking) callback option
- Callback is called every RAF frame with current positions
- Components apply transforms directly to DOM via querySelectorAll
- Zero React re-renders during animation = zero lag
Data flow now:
RAF loop → compute position → onUpdate callback → direct DOM update
Before:
RAF loop → setState → React re-render → useEffect → DOM update
The onUpdate callback receives positions every frame and applies
transforms immediately, bypassing React's batching entirely.
1. Mouse tracking now INSTANT (no lag):
- When tracking, eyes lock directly onto cursor position
- No interpolation/lerp during tracking mode
- Feels like 'locked on target' instead of floating/chasing
2. Energy-based idle behavior:
- High energy (100): frequent movement, shorter pauses, quicker smoothing
- Low energy (0): lazy movement, longer pauses, slower drift
- Energy affects: idle duration, smoothing speed, micro-movement chance
3. Micro-movements for aliveness:
- Small movements (0.2-0.5px) happen randomly
- High energy = 50% chance, Low energy = 10% chance
- Makes Blobbi feel alert and curious
4. Pause behavior scaled by energy:
- Low energy: 50% chance to rest at center
- High energy: 10% chance to rest at center
Values chosen:
- SMOOTHING_MIN = 0.02 (low energy - dreamy drift)
- SMOOTHING_MAX = 0.06 (high energy - alert movement)
- IDLE_DURATION_MIN = 1000ms (high energy)
- IDLE_DURATION_MAX = 6000ms (low energy)
- MICRO_MOVEMENT_MAX = 0.5px (subtle but visible)
Behavior summary:
- Mouse near -> eyes LOCK instantly on cursor
- High energy -> curious, active, moving often
- Low energy -> slower, lazy, but still alive
- Sleeping -> no movement
Root causes of previous issues:
1. Jumping behavior: The old implementation used setState() directly to
random positions instead of interpolating toward them. Each idle
movement instantly teleported the eyes.
2. Mouse tracking failure: The updateMouseTracking callback had isTracking
in its dependencies, causing it to be recreated on every state change.
This restarted the animation frame loop constantly, breaking the
continuous tracking.
3. State conflict: Idle timeouts and tracking animation frames ran
independently and fought each other, causing erratic behavior.
Solution - Single animation loop architecture:
- ONE requestAnimationFrame loop handles ALL animation
- Maintains separate 'current' and 'target' positions
- Always interpolates: current = lerp(current, target, smoothing)
- Idle behavior only sets new targets (doesn't move directly)
- Mouse tracking overrides targets when cursor is nearby
- Clean state machine: tracking active = idle paused
Smoothing values used:
- IDLE_SMOOTHING = 0.03 (very smooth drift)
- TRACKING_SMOOTHING = 0.08 (responsive but not snappy)
- RETURN_SMOOTHING = 0.04 (gentle return to idle)
Timing improvements:
- Idle duration: 3-6 seconds between movements
- 40% chance to pause at center (natural resting)
- Time-scaled smoothing for consistent feel across frame rates
Movement constraints:
- Baby: 2px max, Adult: 2.5px max
- Vertical movement reduced to 70% of horizontal
- State updates throttled (only when position changes > 0.001px)
Root cause: The original implementation had two critical issues:
1. Grouping algorithm assumed highlights immediately followed pupils in SVG,
but SVGs have all pupils first, then all highlights (proximity-based fix)
2. CSS transforms weren't working on SVG <g> elements without transform-box
Fixes:
- Rewrite pupil/highlight detection to use proximity-based grouping (15px radius)
- Add transform-box: fill-box and transform-origin: center inline styles
- Replace CSS keyframe animation with JavaScript-controlled transforms
New features:
- Natural idle behavior with random movement and pauses
- Mouse tracking when cursor is within 200px radius
- Smooth transitions between idle and tracking states
- Different delays for left/right eyes for organic feel
Implementation:
- useBlobbiEyes hook manages animation state and mouse tracking
- addEyeAnimation wraps pupil+highlight elements in <g class="blobbi-eye">
- Visual components apply transforms via DOM refs in useEffect
- CSS provides transition timing (.3s idle, .1s tracking)
The nullish coalescing operator (??) treats null as nullish, so
`null ?? <RightSidebar />` was falling through to the default.
Use an explicit null check so pages can hide the sidebar by
passing rightSidebar: null.
Add eye animation utility that:
- Detects pupil and highlight elements via gradient patterns and dark fills
- Wraps pupil+highlight elements in animated <g> groups
- Applies CSS keyframe animation for gentle wandering eye movement
- Uses different delays for left/right eyes for natural feel
- Only animates when awake (skips sleeping state)
Integrates animation into both BlobbiBabyVisual and BlobbiAdultVisual
components for a more lifelike appearance.
- Remove redundant Notification.requestPermission() from enable() since
the caller (NotificationSettings) already handles it from the user gesture.
On iOS this second call could behave unexpectedly.
- Add console.debug logging throughout the enable flow and RPC send for
diagnosing push registration issues in production.
- NotificationSettings: on web, drive the push toggle from the hook's
enabled state (actual browser subscription) instead of the NIP-78
notificationsEnabled setting which can be stale across devices.
NIP-78 is still used for the Capacitor/Android path.
- useNativeNotifications: remove the web push auto-disable effect that
raced against encrypted settings loading, causing disablePush() on
every page load before settings resolved.
- usePushNotifications: remove REGISTERED_KEY localStorage check from
the mount restore logic; derive enabled purely from
pushManager.getSubscription() like the working Ghastly reference.
Remove dead REGISTERED_KEY code entirely.
- Add adultType field to BlobbiCompanion interface
- Parse adult_type tag in parseBlobbiEvent from kind 31124
- Pass adult.evolutionForm in toBlobbiForVisual adapter
- Seed-derived form is now only used as fallback when no adult_type tag exists
- Add adult-blobbi module with types, SVG resolver, and customizer
- Support 16 adult forms: bloomi, breezy, cacti, catti, cloudi, crysti,
droppi, flammi, froggi, leafy, mushie, owli, pandi, rocky, rosey, starri
- Each form has base and sleeping SVG variants
- Adult form resolved from blobbi.adult.evolutionForm or derived from seed
- Color customization applies to body and pupil gradients via pattern matching
- BlobbiAdultVisual component with reaction animations support
- Replace adult placeholder in BlobbiStageVisual with real visuals
- Add onRecordingStart/onRecordingStop callbacks to InlineSingCard
- Move singing reaction trigger from card open to actual recording start
- Reduce sing bounce animation movement (6px → 3px for baby, 4px → 2px for egg)
- Slow down sing bounce animation (0.4s → 0.5s for baby, 0.5s → 0.6s for egg)
- Change Record button label to Sing