From 03d174e5ccf89e9950189cf5e89bac7d0d2b63aa Mon Sep 17 00:00:00 2001 From: filemon Date: Sat, 18 Apr 2026 19:26:24 -0300 Subject: [PATCH] Add one-shot eye-open animation on wake-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Blobbi transitions from sleeping to awake, the eyes now visibly open over 400ms with an ease-in-out curve before the normal blink/gaze loop resumes. The wake-up animation mirrors the sleep-entry animation: - runWakeUpAnimation() queries fresh DOM elements from containerRef - Sets clip-rects to the closed position (using BLINK_CLOSED_AMOUNT) - Animates from closed to open, then calls onComplete - The normal awake animation loop only starts after completion - This prevents two rAF loops from fighting over clip-rect attributes The animation only fires on genuine sleeping→awake transitions via wasSleepingRef, not on mount or refresh when Blobbi is already awake. If cancelled mid-animation (e.g. quick sleep re-toggle), the onComplete callback is still invoked so the hook does not get stuck. --- src/blobbi/ui/lib/useBlobbiEyes.ts | 139 ++++++++++++++++++++++++++++- 1 file changed, 136 insertions(+), 3 deletions(-) diff --git a/src/blobbi/ui/lib/useBlobbiEyes.ts b/src/blobbi/ui/lib/useBlobbiEyes.ts index 7a2fe8d5..8daafcee 100644 --- a/src/blobbi/ui/lib/useBlobbiEyes.ts +++ b/src/blobbi/ui/lib/useBlobbiEyes.ts @@ -310,6 +310,122 @@ function runSleepEntryAnimation(container: HTMLElement): (() => void) { }; } +// ─── Wake-Up Animation ─────────────────────────────────────────────────────── + +/** Duration of the eye-open animation when waking up (ms). */ +const WAKE_UP_DURATION = 400; + +/** + * Run a one-shot eye-open animation on the *freshly rendered* awake SVG. + * + * The awake recipe sets clip-rects to the fully open position in the SVG + * string. This function temporarily sets them to the closed position and + * animates to open over WAKE_UP_DURATION ms, then calls `onComplete` so the + * normal awake animation loop can start. + * + * IMPORTANT: All DOM queries use `container` (the live containerRef.current), + * never stale cached refs. This mirrors the sleep-entry animation approach. + * + * Returns a cleanup function that cancels the rAF loop if the component + * unmounts or the effect re-runs mid-animation. + */ +function runWakeUpAnimation( + container: HTMLElement, + onComplete: () => void, +): (() => void) { + // Query fresh blink groups from the live awake SVG + const blinkGroups = container.querySelectorAll(`.${EYE_CLASSES.blink}`); + if (blinkGroups.length === 0) { + onComplete(); + return () => {}; + } + + interface EyeTarget { + clipRect: SVGRectElement; + openY: number; + openHeight: number; + closedY: number; + closedHeight: number; + } + + const targets: EyeTarget[] = []; + + blinkGroups.forEach((group) => { + const clipId = group.getAttribute(EYE_DATA_ATTRS.clipId); + const clipTopAttr = group.getAttribute(EYE_DATA_ATTRS.clipTop) + ?? group.getAttribute(EYE_DATA_ATTRS.legacyEyeTop); + const clipHeightAttr = group.getAttribute(EYE_DATA_ATTRS.clipHeight); + + if (!clipId || !clipTopAttr || !clipHeightAttr) return; + + const clipRect = container.querySelector(`#${clipId} rect`) as SVGRectElement | null; + if (!clipRect) return; + + // Open geometry from data attributes (= the full eye bounds) + const openY = parseFloat(clipTopAttr); + const openHeight = parseFloat(clipHeightAttr); + + // Closed geometry: shift down by BLINK_CLOSED_AMOUNT (same constant the + // blink system and sleeping recipe use for full eye closure) + const closedOffset = openHeight * BLINK_CLOSED_AMOUNT; + const closedY = openY + closedOffset; + const closedHeight = openHeight - closedOffset; + + targets.push({ clipRect, openY, openHeight, closedY, closedHeight }); + }); + + if (targets.length === 0) { + onComplete(); + return () => {}; + } + + // Start from closed position + targets.forEach(({ clipRect, closedY, closedHeight }) => { + clipRect.setAttribute('y', closedY.toString()); + clipRect.setAttribute('height', Math.max(0.1, closedHeight).toString()); + }); + + // Animate from closed → open + let rafId: number | null = null; + let startTime: number | null = null; + let completed = false; + + const step = (timestamp: number) => { + if (startTime === null) startTime = timestamp; + const elapsed = timestamp - startTime; + // ease-in-out matching the sleep-entry curve + const rawT = Math.min(elapsed / WAKE_UP_DURATION, 1); + const t = rawT < 0.5 + ? 2 * rawT * rawT + : 1 - Math.pow(-2 * rawT + 2, 2) / 2; + + targets.forEach(({ clipRect, openY, openHeight, closedY, closedHeight }) => { + const y = closedY + (openY - closedY) * t; + const h = closedHeight + (openHeight - closedHeight) * t; + clipRect.setAttribute('y', y.toString()); + clipRect.setAttribute('height', Math.max(0.1, h).toString()); + }); + + if (rawT < 1) { + rafId = requestAnimationFrame(step); + } else { + // At t=1 the DOM matches the awake SVG's fully-open state. + // Hand off to the normal awake animation loop. + completed = true; + onComplete(); + } + }; + + rafId = requestAnimationFrame(step); + + return () => { + if (rafId !== null) cancelAnimationFrame(rafId); + // If cancelled before completion (e.g. quick sleep toggle), still let + // the awake loop start so the hook doesn't get stuck. + if (!completed) onComplete(); + }; +} + // ─── Hook ───────────────────────────────────────────────────────────────────── export function useBlobbiEyes( @@ -385,7 +501,10 @@ export function useBlobbiEyes( }; } - // Track that we're now awake (for detecting future awake→sleep transitions) + // ── Wake-up transition detection ─────────────────────────────────── + // Only animate on a genuine sleeping→awake transition, not on mount or + // refresh when Blobbi is already awake. + const isWakeTransition = wasSleepingRef.current; wasSleepingRef.current = false; // ─── Cache Eye Elements ───────────────────────────────────────────── @@ -562,12 +681,26 @@ export function useBlobbiEyes( animationRef.current = requestAnimationFrame(animate); }; - // Start animation loop - animationRef.current = requestAnimationFrame(animate); + // ── Start awake animation (with optional wake-up transition) ───────── + // If this is a sleeping→awake transition, play the eye-open animation + // first, then hand off to the normal blink/gaze loop. This prevents + // two rAF loops from fighting over the same clip-rect attributes. + let cancelWakeAnimation: (() => void) | null = null; + + const startAwakeLoop = () => { + animationRef.current = requestAnimationFrame(animate); + }; + + if (isWakeTransition && containerRef.current) { + cancelWakeAnimation = runWakeUpAnimation(containerRef.current, startAwakeLoop); + } else { + startAwakeLoop(); + } // ─── Cleanup ──────────────────────────────────────────────────────── return () => { + cancelWakeAnimation?.(); if (animationRef.current) { cancelAnimationFrame(animationRef.current); }