From 384936f10696d06184dec767a19ead50d2ddc794 Mon Sep 17 00:00:00 2001 From: filemon Date: Tue, 31 Mar 2026 03:53:17 -0300 Subject: [PATCH] refactor(blobbi): extract pure SVG renderers and add explicit render mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Architecture refactor for the Blobbi visual system: 1. Centralized debug helper (src/blobbi/ui/lib/debug.ts): - Replaces all scattered console.log/trace instrumentation - Single BLOBBI_DEBUG flag, only logs in DEV mode when enabled - Typed debug categories for filtering 2. Explicit render mode API (BlobbiRenderMode: 'page' | 'companion'): - Replaces implicit companion detection via eye offset prop sniffing - Controls tracking, reaction class suppression, and future behaviors - Default is 'page' — no changes needed for existing BlobbiPage callers 3. Pure SVG renderer extraction: - BlobbiAdultSvgRenderer: resolve → customize → animate → recipe → sanitize → innerHTML - BlobbiBabySvgRenderer: same pipeline for baby stage - These components know nothing about hooks, modes, or runtime state - Only rerender when visual content changes (blobbi, recipe, emotion, bodyEffects) 4. Visual wrappers simplified: - BlobbiAdultVisual/BlobbiBabyVisual own the containerRef, eye hooks, and reaction CSS classes — delegate SVG output to the renderers - ~480 lines removed across the visual layer Net result: -305 lines, zero debug console spam, clean separation between SVG pipeline, eye behavior, and companion runtime. --- .../companion/components/BlobbiCompanion.tsx | 15 -- .../components/BlobbiCompanionVisual.tsx | 186 +++----------- src/blobbi/ui/BlobbiAdultSvgRenderer.tsx | 105 ++++++++ src/blobbi/ui/BlobbiAdultVisual.tsx | 238 +++++------------- src/blobbi/ui/BlobbiBabySvgRenderer.tsx | 98 ++++++++ src/blobbi/ui/BlobbiBabyVisual.tsx | 201 +++++---------- src/blobbi/ui/lib/debug.ts | 44 ++++ src/blobbi/ui/lib/types.ts | 15 ++ 8 files changed, 422 insertions(+), 480 deletions(-) create mode 100644 src/blobbi/ui/BlobbiAdultSvgRenderer.tsx create mode 100644 src/blobbi/ui/BlobbiBabySvgRenderer.tsx create mode 100644 src/blobbi/ui/lib/debug.ts diff --git a/src/blobbi/companion/components/BlobbiCompanion.tsx b/src/blobbi/companion/components/BlobbiCompanion.tsx index 5393dc97..ce588532 100644 --- a/src/blobbi/companion/components/BlobbiCompanion.tsx +++ b/src/blobbi/companion/components/BlobbiCompanion.tsx @@ -79,11 +79,6 @@ interface BlobbiCompanionProps { debugMode?: boolean; } -// ─── DEBUG: Render frequency tracking ───────────────────────────────────────── -const _companionRenderCount = { current: 0 }; -const _companionLastLogTime = { current: 0 }; -// ────────────────────────────────────────────────────────────────────────────── - export function BlobbiCompanion({ companion, state, @@ -110,16 +105,6 @@ export function BlobbiCompanion({ const containerRef = useRef(null); const [animationTime, setAnimationTime] = useState(0); - // ─── DEBUG: Log render frequency (once per second summary) ───────────── - _companionRenderCount.current++; - const now = performance.now(); - if (now - _companionLastLogTime.current > 2000) { - console.log(`[BlobbiCompanion] ${_companionRenderCount.current} renders in last 2s`); - _companionRenderCount.current = 0; - _companionLastLogTime.current = now; - } - // ───────────────────────────────────────────────────────────────────────── - // Click detection - distinguishes click from drag const clickDetection = useClickDetection({ onClick, diff --git a/src/blobbi/companion/components/BlobbiCompanionVisual.tsx b/src/blobbi/companion/components/BlobbiCompanionVisual.tsx index 861b3368..8c2b183c 100644 --- a/src/blobbi/companion/components/BlobbiCompanionVisual.tsx +++ b/src/blobbi/companion/components/BlobbiCompanionVisual.tsx @@ -7,6 +7,8 @@ * - Outer shell: handles per-frame updates (float, shadow, drag state) — rerenders freely * - Inner MemoizedBlobbiVisual: renders the actual SVG — only rerenders when visual inputs change * - Eye gaze is driven imperatively via ref (no React rerenders for gaze) + * - Reaction CSS classes (sway/bounce) applied on the outer float wrapper, + * NOT on the SVG container, to keep the SVG DOM node stable. */ import { useMemo, useRef, memo, type RefObject } from 'react'; @@ -18,56 +20,35 @@ import { useEffectiveEmotion } from '@/blobbi/dev/EmotionDevContext'; import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types'; import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe'; import type { BodyEffectsSpec } from '@/blobbi/ui/lib/bodyEffects'; -// BlobbiReactionState not needed — reaction classes applied on outer wrapper, not passed to memoized visual import type { Blobbi } from '@/blobbi/core/types/blobbi'; import { cn } from '@/lib/utils'; import type { CompanionData, EyeOffset, CompanionDirection } from '../types/companion.types'; +// ─── Props ──────────────────────────────────────────────────────────────────── + interface BlobbiCompanionVisualProps { - /** Companion data */ companion: CompanionData; - /** Size in pixels */ size: number; - /** Ref-based eye offset for imperative gaze control (no rerenders) */ eyeOffsetRef: RefObject; - /** Facing direction (used for gaze, not for flipping) */ direction: CompanionDirection; - /** Whether the companion is being dragged */ isDragging: boolean; - /** Whether the companion is walking */ isWalking: boolean; - /** Floating animation offset for gentle sway */ floatOffset?: { x: number; y: number; rotation: number }; - /** Whether Blobbi is on or near the ground (affects shadow visibility) */ isOnGround?: boolean; - /** Distance from ground in pixels (for shadow fade, 0 = on ground) */ distanceFromGround?: number; - /** Pre-resolved visual recipe. Takes precedence over `emotion`. */ recipe?: BlobbiVisualRecipe; - /** Label for the recipe (CSS class names). */ recipeLabel?: string; - /** Named emotion preset (convenience). Ignored when `recipe` is provided. */ emotion?: BlobbiEmotion; - /** - * Body-level visual effects — for manual/external use only. - * Status-reaction body effects are already folded into the recipe. - */ bodyEffects?: BodyEffectsSpec; - /** Additional class names */ className?: string; - /** Debug mode - shows visual boundaries */ debugMode?: boolean; } // ─── Memoized Inner Visual ──────────────────────────────────────────────────── // -// This component renders the actual Blobbi SVG (BlobbiAdultVisual / BlobbiBabyVisual). -// It is wrapped in React.memo with a custom comparator so it only rerenders when -// the actual visual inputs change — NOT when per-frame props like floatOffset, -// isDragging, isWalking, or eyeOffset change. -// -// Eye gaze is controlled imperatively: the ref is passed to useExternalEyeOffset -// inside the visual components, which reads from it in its own RAF loop. +// Renders BlobbiAdultVisual / BlobbiBabyVisual with renderMode="companion". +// Wrapped in React.memo so it only rerenders when visual content changes. +// Per-frame props (float, drag, walking, gaze) are excluded. interface MemoizedBlobbiVisualProps { stage: 'baby' | 'adult'; @@ -79,13 +60,6 @@ interface MemoizedBlobbiVisualProps { bodyEffects?: BodyEffectsSpec; } -/** - * Memoized inner visual — renders the actual SVG. - * - * Does NOT receive reaction/walking/dragging props. - * CSS sway/bounce classes are applied on an outer wrapper in BlobbiCompanionVisual. - * This component only rerenders when visual content (recipe, emotion, blobbi data) changes. - */ const MemoizedBlobbiVisual = memo(function MemoizedBlobbiVisual({ stage, blobbi, @@ -99,6 +73,7 @@ const MemoizedBlobbiVisual = memo(function MemoizedBlobbiVisual({ return ( ); }, (prev, next) => { - // Custom comparator: only rerender when visual inputs change return ( prev.stage === next.stage && prev.blobbi === next.blobbi && @@ -131,15 +106,10 @@ const MemoizedBlobbiVisual = memo(function MemoizedBlobbiVisual({ prev.recipeLabel === next.recipeLabel && prev.emotion === next.emotion && prev.bodyEffects === next.bodyEffects - // eyeOffsetRef is a stable ref — never changes identity ); }); -// ─── DEBUG: Companion prop stability ────────────────────────────────────────── -const _visualRenderCount = { current: 0 }; -const _visualPrevCompanionRef = { current: null as CompanionData | null }; -const _visualPrevBlobbiRef = { current: null as ReturnType | null }; -// ────────────────────────────────────────────────────────────────────────────── +// ─── Component ──────────────────────────────────────────────────────────────── export function BlobbiCompanionVisual({ companion, @@ -159,137 +129,69 @@ export function BlobbiCompanionVisual({ debugMode = false, }: BlobbiCompanionVisualProps) { const containerRef = useRef(null); - + const blobbi = useMemo(() => companionDataToBlobbi(companion), [companion]); - // ─── DEBUG: Track companion → blobbi object stability ───────────────── - _visualRenderCount.current++; - if (_visualRenderCount.current <= 5 || _visualRenderCount.current % 100 === 0) { - console.log(`[CompanionVisual] render #${_visualRenderCount.current}`); - } - if (companion !== _visualPrevCompanionRef.current) { - console.log(`%c[CompanionVisual] companion REFERENCE changed (render #${_visualRenderCount.current})`, 'color: #8b5cf6; font-weight: bold'); - _visualPrevCompanionRef.current = companion; - } - if (blobbi !== _visualPrevBlobbiRef.current) { - console.log(`%c[CompanionVisual] blobbi REFERENCE changed (render #${_visualRenderCount.current}) — this triggers SVG rebuild`, 'color: #ef4444; font-weight: bold'); - _visualPrevBlobbiRef.current = blobbi; - } - // ───────────────────────────────────────────────────────────────────────── - // DEV ONLY: Get effective emotion from dev context (overrides production emotions) const devEmotion = useEffectiveEmotion(); const hasDevOverride = devEmotion !== 'neutral'; - - // Final rendering: dev override > props from status reaction system + const effectiveRecipe = hasDevOverride ? undefined : recipeProp; const effectiveRecipeLabel = hasDevOverride ? undefined : recipeLabelProp; const effectiveEmotion = hasDevOverride ? devEmotion : (emotionProp ?? 'neutral'); const effectiveBodyEffects = hasDevOverride ? undefined : bodyEffectsProp; - - // Build transform for floating animation - // No flipping based on direction - Blobbi always faces the same way + + // Float transform const blobbiTransform = useMemo(() => { const transforms: string[] = []; - if (floatOffset.x !== 0 || floatOffset.y !== 0) { transforms.push(`translate(${floatOffset.x}px, ${floatOffset.y}px)`); } if (floatOffset.rotation !== 0) { transforms.push(`rotate(${floatOffset.rotation}deg)`); } - return transforms.length > 0 ? transforms.join(' ') : undefined; }, [floatOffset]); - - // Determine reaction state for CSS animations - // - happy: when being dragged (Blobbi enjoys interaction) - // - swaying: when walking (natural movement animation) - // - idle: default state + + // Reaction state for CSS animations on the OUTER wrapper const reaction = isDragging ? 'happy' : isWalking ? 'swaying' : 'idle'; - - // Shadow visibility and appearance based on ground proximity - // Shadow should only appear when Blobbi is on or very near the ground - const SHADOW_FADE_DISTANCE = 30; // Shadow fully fades at this distance from ground + + // ── Shadow ───────────────────────────────────────────────────────────────── + const SHADOW_FADE_DISTANCE = 30; const SHADOW_MAX_OPACITY = 0.35; - - // Calculate shadow visibility based on actual ground distance, not just float offset + const showShadow = isOnGround && !isDragging && distanceFromGround < SHADOW_FADE_DISTANCE; - - // Shadow fades as Blobbi gets farther from ground - // Also factor in the float animation offset for subtle breathing effect const floatHeight = Math.abs(floatOffset.y); const groundFadeRatio = Math.max(0, 1 - distanceFromGround / SHADOW_FADE_DISTANCE); - const floatFadeRatio = Math.max(0.85, 1 - floatHeight * 0.02); // Subtle fade during float + const floatFadeRatio = Math.max(0.85, 1 - floatHeight * 0.02); const shadowOpacity = SHADOW_MAX_OPACITY * groundFadeRatio * floatFadeRatio; - const shadowScale = 0.9 + 0.1 * groundFadeRatio * floatFadeRatio; // Slightly smaller when lifting - - // Suppress unused variable warning for direction (kept for API compatibility) - void direction; - + const shadowScale = 0.9 + 0.1 * groundFadeRatio * floatFadeRatio; + + void direction; // kept for API compatibility + return ( -
- {/* DEBUG: Container and alignment markers */} + {/* Debug alignment markers */} {debugMode && ( <> - {/* Container outline - lime */} -
- {/* 88% line from top (where SVG body bottom should be before shift) - yellow */} -
- {/* 100% line (container bottom where body should touch after shift) - cyan */} -
- {/* Label showing the expected shift */} -
+
+
+
+
shift: {size * 0.12}px
)} - - {/* Floor shadow - only visible when Blobbi is on/near the ground */} - {/* Hidden during: dragging, entry animations, falling, or when far from ground */} + + {/* Floor shadow */} {!debugMode && showShadow && shadowOpacity > 0.01 && (
)} - - {/* Blobbi visual with floating transform */} - {/* - The Blobbi SVG has empty space: 15% at top (body starts at y=15), 12% at bottom (body ends at y=88). - To align the visible body bottom with the container bottom, we shift down by 12% of container size. - This is applied BEFORE the float transform so the ground position is correct. - */} + + {/* Float + reaction wrapper */}
- {/* Memoized visual: only rerenders when visual inputs change */} {(companion.stage === 'baby' || companion.stage === 'adult') && ( (null); + + const customizedSvg = useMemo(() => { + debugBlobbi('svg-rebuild', 'adult customizedSvg rebuild'); + + const { form, svg } = resolveAdultSvgWithForm(blobbi, { isSleeping }); + const colorizedSvg = customizeAdultSvgFromBlobbi(svg, form, blobbi, isSleeping); + + if (!isSleeping) { + let animatedSvg = addEyeAnimation(colorizedSvg, { baseColor: blobbi.baseColor, instanceId: blobbi.id }); + + if (recipeProp) { + animatedSvg = applyVisualRecipe(animatedSvg, recipeProp, recipeLabel ?? 'status', 'adult', form, blobbi.id); + } else if (emotion !== 'neutral') { + const resolved = resolveVisualRecipe(emotion); + animatedSvg = applyVisualRecipe(animatedSvg, resolved, emotion, 'adult', form, blobbi.id); + } + + if (bodyEffects && !recipeProp) { + animatedSvg = applyBodyEffects(animatedSvg, { ...bodyEffects, idPrefix: bodyEffects.idPrefix ?? blobbi.id }); + } + + return animatedSvg; + } + + return colorizedSvg; + }, [blobbi, isSleeping, recipeProp, recipeLabel, emotion, bodyEffects]); + + const safeSvg = useMemo(() => sanitizeBlobbiSvg(customizedSvg), [customizedSvg]); + + return ( +
+ ); +} diff --git a/src/blobbi/ui/BlobbiAdultVisual.tsx b/src/blobbi/ui/BlobbiAdultVisual.tsx index 3042a4b2..95975519 100644 --- a/src/blobbi/ui/BlobbiAdultVisual.tsx +++ b/src/blobbi/ui/BlobbiAdultVisual.tsx @@ -1,143 +1,92 @@ /** - * BlobbiAdultVisual - Reusable component for rendering Blobbi adults + * BlobbiAdultVisual — Visual wrapper for rendering Blobbi adults. * - * Uses the adult-blobbi module for SVG resolution and customization. - * Handles awake vs sleeping states automatically. - * Supports multiple adult evolution forms. - * Eyes always track the mouse cursor in real-time. + * Responsibilities: + * - Owns the container ref for eye hooks to query SVG DOM + * - Runs useBlobbiEyes (blink RAF loop, optional mouse tracking) + * - Runs useExternalEyeOffset (companion gaze RAF loop) + * - Applies reaction CSS classes (sway/bounce) in page mode + * - Delegates SVG rendering to BlobbiAdultSvgRenderer * - * Accepts either: - * - `recipe` + `recipeLabel`: a pre-resolved visual recipe (recipe-first path - * from useStatusReaction). The recipe includes body effects — no separate - * bodyEffects prop is needed for this path. - * - `emotion`: a named emotion preset (convenience path, resolved internally) + * The SVG renderer is a separate component so the dangerouslySetInnerHTML + * node stays mounted even when wrapper-level props change (reaction, + * className toggles, etc.). * - * An optional `bodyEffects` prop is available for manual/external use cases - * outside the status reaction system (e.g. dev tools, previews). It is NOT - * fed from useStatusReaction to avoid double-applying body effects. + * Render modes: + * - 'page' (default): Mouse tracking enabled, reaction classes applied here. + * - 'companion': Mouse tracking disabled (gaze via ref), reaction classes + * suppressed (applied by outer companion wrapper instead). */ -import { useMemo, useRef, useEffect, type RefObject } from 'react'; +import { useRef, type RefObject } from 'react'; -import { resolveAdultSvgWithForm, customizeAdultSvgFromBlobbi } from '@/blobbi/adult-blobbi'; import { cn } from '@/lib/utils'; -import { sanitizeBlobbiSvg } from '@/lib/sanitizeBlobbiSvg'; -import { addEyeAnimation } from './lib/eye-animation'; -import { resolveVisualRecipe, applyVisualRecipe, type BlobbiVisualRecipe } from './lib/recipe'; -import type { BlobbiEmotion } from './lib/emotion-types'; -import { applyBodyEffects, type BodyEffectsSpec } from './lib/bodyEffects'; import { useBlobbiEyes, type BlobbiLookMode } from './lib/useBlobbiEyes'; import { useExternalEyeOffset } from './lib/useExternalEyeOffset'; -import type { ExternalEyeOffset, BlobbiReactionState } from './lib/types'; +import type { ExternalEyeOffset, BlobbiReactionState, BlobbiRenderMode } from './lib/types'; +import type { BlobbiVisualRecipe } from './lib/recipe'; +import type { BlobbiEmotion } from './lib/emotion-types'; +import type { BodyEffectsSpec } from './lib/bodyEffects'; import type { Blobbi } from '@/blobbi/core/types/blobbi'; import { isBlobbiSleeping } from '@/blobbi/core/types/blobbi'; +import { BlobbiAdultSvgRenderer } from './BlobbiAdultSvgRenderer'; // Re-export types for backwards compatibility export type { ExternalEyeOffset }; -/** - * Reaction states for adult Blobbi animations - * @deprecated Use BlobbiReactionState from './lib/types' instead - */ +/** @deprecated Use BlobbiReactionState from './lib/types' instead */ export type AdultReactionState = BlobbiReactionState; export interface BlobbiAdultVisualProps { /** The Blobbi data */ blobbi: Blobbi; /** Reaction state for music/sing animations */ - reaction?: AdultReactionState; + reaction?: BlobbiReactionState; /** Controls eye tracking behavior (default: 'follow-pointer') */ lookMode?: BlobbiLookMode; /** Disable blinking animation (for photo/export mode) */ disableBlink?: boolean; - /** - * External eye offset from companion system (value-based — causes rerenders). - * When provided, bypasses internal mouse tracking and uses this offset directly. - */ + /** External eye offset (value-based — causes rerenders). */ externalEyeOffset?: ExternalEyeOffset; - /** - * Ref-based external eye offset (imperative — no rerenders). - * Preferred for companion mode. When provided, takes precedence over externalEyeOffset. - */ + /** Ref-based external eye offset (imperative — no rerenders). Preferred for companion mode. */ externalEyeOffsetRef?: RefObject; - /** - * Pre-resolved visual recipe. When provided, takes precedence over `emotion`. - * This is the recipe-first rendering path used by useStatusReaction. - */ + /** Render mode. Default: 'page'. */ + renderMode?: BlobbiRenderMode; + /** Pre-resolved visual recipe. Takes precedence over `emotion`. */ recipe?: BlobbiVisualRecipe; - /** - * Label for the recipe (used in CSS class names). Required when `recipe` is provided. - */ + /** Label for the recipe (used in CSS class names). */ recipeLabel?: string; - /** - * Named emotion preset (convenience path). - * Ignored when `recipe` is provided. - * Default: 'neutral' (no modifications) - */ + /** Named emotion preset. Ignored when `recipe` is provided. Default: 'neutral' */ emotion?: BlobbiEmotion; - /** - * Body-level visual effects (dirt marks, stink clouds, etc.). - * Optional — for manual/external use cases only. - * Do NOT pass status-reaction body effects here; those are already - * folded into the recipe and applied by applyVisualRecipe(). - */ + /** Body-level visual effects (manual/external use only). */ bodyEffects?: BodyEffectsSpec; /** Additional CSS classes for the container */ className?: string; } -// ─── Component ──────────────────────────────────────────────────────────────── - -// ─── DEBUG: Animation lifecycle instrumentation ────────────────────────────── -const _adultSvgRebuildCount = { current: 0 }; -const _adultSafeSvgCount = { current: 0 }; -const _adultRenderCount = { current: 0 }; -const _adultPrevProps = { current: null as Record | null }; -// ────────────────────────────────────────────────────────────────────────────── - -export function BlobbiAdultVisual({ blobbi, reaction = 'idle', lookMode = 'follow-pointer', disableBlink = false, externalEyeOffset, externalEyeOffsetRef, recipe: recipeProp, recipeLabel, emotion = 'neutral', bodyEffects, className }: BlobbiAdultVisualProps) { +export function BlobbiAdultVisual({ + blobbi, + reaction = 'idle', + lookMode = 'follow-pointer', + disableBlink = false, + externalEyeOffset, + externalEyeOffsetRef, + renderMode = 'page', + recipe, + recipeLabel, + emotion = 'neutral', + bodyEffects, + className, +}: BlobbiAdultVisualProps) { const isSleeping = isBlobbiSleeping(blobbi); const containerRef = useRef(null); + const isCompanion = renderMode === 'companion'; - // ─── DEBUG: Track renders and prop changes ─────────────────────────────── - _adultRenderCount.current++; - const isCompanion = !!(externalEyeOffset || externalEyeOffsetRef); - if (isCompanion) { - const currentProps: Record = { - blobbiId: blobbi.id, - blobbiRef: blobbi, - isSleeping, - recipeProp, - recipeLabel, - emotion, - bodyEffects, - }; - const prev = _adultPrevProps.current; - if (prev) { - const changed: string[] = []; - for (const key of Object.keys(currentProps)) { - if (currentProps[key] !== prev[key]) { - changed.push(key); - } - } - if (changed.length > 0) { - console.log(`%c[AdultVisual] COMPANION render #${_adultRenderCount.current} — props changed: ${changed.join(', ')}`, 'color: #f59e0b; font-weight: bold'); - for (const key of changed) { - console.log(` ${key}: `, prev[key], ' → ', currentProps[key]); - } - } - } - _adultPrevProps.current = currentProps; - } - // ───────────────────────────────────────────────────────────────────────── - - // Reaction controls CSS sway/bounce classes. Suppressed when sleeping. - // NOTE: In companion mode, these classes are applied on an OUTER wrapper - // (BlobbiCompanionVisual) rather than on this component, so this component - // can remain a pure SVG renderer that doesn't rerender for walking changes. const effectiveReaction = isSleeping ? 'idle' : reaction; + // ── Eye hooks ────────────────────────────────────────────────────────────── + useBlobbiEyes(containerRef, { isSleeping, maxMovement: 2.5, @@ -154,77 +103,9 @@ export function BlobbiAdultVisual({ blobbi, reaction = 'idle', lookMode = 'follo variant: 'adult', }); - const customizedSvg = useMemo(() => { - // ─── DEBUG: Track SVG rebuilds ────────────────────────────────────── - if (isCompanion) { - _adultSvgRebuildCount.current++; - console.log(`%c[AdultVisual] COMPANION customizedSvg rebuild #${_adultSvgRebuildCount.current}`, 'color: #ef4444; font-weight: bold'); - console.trace('[AdultVisual] SVG rebuild stack trace'); - } - // ──────────────────────────────────────────────────────────────────── - - const { form, svg } = resolveAdultSvgWithForm(blobbi, { isSleeping }); - const colorizedSvg = customizeAdultSvgFromBlobbi(svg, form, blobbi, isSleeping); - - if (!isSleeping) { - let animatedSvg = addEyeAnimation(colorizedSvg, { baseColor: blobbi.baseColor, instanceId: blobbi.id }); - - // Recipe-first path: use pre-resolved recipe if provided. - // applyVisualRecipe() handles everything including body effects - // embedded in the recipe, so no separate applyBodyEffects() needed. - if (recipeProp) { - animatedSvg = applyVisualRecipe(animatedSvg, recipeProp, recipeLabel ?? 'status', 'adult', form, blobbi.id); - } else if (emotion !== 'neutral') { - // Convenience path: resolve named emotion preset - const resolved = resolveVisualRecipe(emotion); - animatedSvg = applyVisualRecipe(animatedSvg, resolved, emotion, 'adult', form, blobbi.id); - } - - // Manual body effects prop — only applied when no recipe was provided, - // since applyVisualRecipe() already applies recipe.bodyEffects. - if (bodyEffects && !recipeProp) { - animatedSvg = applyBodyEffects(animatedSvg, { ...bodyEffects, idPrefix: bodyEffects.idPrefix ?? blobbi.id }); - } - - return animatedSvg; - } - - return colorizedSvg; - }, [blobbi, isSleeping, recipeProp, recipeLabel, emotion, bodyEffects]); - - const safeSvg = useMemo(() => { - // ─── DEBUG: Track sanitization rebuilds ────────────────────────────── - if (isCompanion) { - _adultSafeSvgCount.current++; - console.log(`%c[AdultVisual] COMPANION safeSvg rebuild #${_adultSafeSvgCount.current}`, 'color: #ef4444'); - } - // ──────────────────────────────────────────────────────────────────── - return sanitizeBlobbiSvg(customizedSvg); - }, [customizedSvg]); - - // ─── DEBUG: Track DOM node identity (detect remounts vs rerenders) ────── - const prevSvgNodeRef = useRef(null); - useEffect(() => { - if (!isCompanion || !containerRef.current) return; - const svgNode = containerRef.current.querySelector('svg'); - if (svgNode && svgNode !== prevSvgNodeRef.current) { - if (prevSvgNodeRef.current === null) { - console.log('%c[AdultVisual] COMPANION: SVG node mounted (first time)', 'color: #22c55e'); - } else { - console.log('%c[AdultVisual] COMPANION: SVG DOM NODE REPLACED! (animations killed)', 'color: #ef4444; font-weight: bold; font-size: 14px'); - } - prevSvgNodeRef.current = svgNode; - // Check for SMIL animations - const animates = svgNode.querySelectorAll('animate, animateTransform'); - console.log(` SVG has ${animates.length} SMIL animation elements`); - } - }); - // ───────────────────────────────────────────────────────────────────────── - - // In companion mode, reaction CSS classes are applied by the outer wrapper - // (BlobbiCompanionVisual) so this div stays className-stable and - // dangerouslySetInnerHTML doesn't cause the browser to replace the SVG node. - const applyReactionClasses = !isCompanion; + // ── Render ───────────────────────────────────────────────────────────────── + // In companion mode, reaction classes are applied by an outer wrapper to + // keep the dangerouslySetInnerHTML div className-stable. return (
+ > + +
); } diff --git a/src/blobbi/ui/BlobbiBabySvgRenderer.tsx b/src/blobbi/ui/BlobbiBabySvgRenderer.tsx new file mode 100644 index 00000000..ebd2832a --- /dev/null +++ b/src/blobbi/ui/BlobbiBabySvgRenderer.tsx @@ -0,0 +1,98 @@ +/** + * BlobbiBabySvgRenderer — Pure SVG rendering component for baby Blobbi. + * + * This component is the leaf node of the visual pipeline. It: + * 1. Resolves the base SVG for the baby + * 2. Customizes colors and unique IDs + * 3. Adds eye animation infrastructure (blink clip-paths, gaze groups) + * 4. Applies visual recipe or emotion preset + * 5. Applies manual body effects (when no recipe is provided) + * 6. Sanitizes the SVG + * 7. Renders via dangerouslySetInnerHTML + * + * It does NOT know about: + * - Eye tracking hooks (useBlobbiEyes / useExternalEyeOffset) + * - Render mode (page vs companion) + * - Reaction CSS classes (sway / bounce) + * - Companion runtime (drag, float, position) + */ + +import { useMemo, useRef } from 'react'; + +import { resolveBabySvg, customizeBabySvgFromBlobbi } from '@/blobbi/baby-blobbi'; +import { sanitizeBlobbiSvg } from '@/lib/sanitizeBlobbiSvg'; + +import { addEyeAnimation } from './lib/eye-animation'; +import { resolveVisualRecipe, applyVisualRecipe, type BlobbiVisualRecipe } from './lib/recipe'; +import type { BlobbiEmotion } from './lib/emotion-types'; +import { applyBodyEffects, type BodyEffectsSpec } from './lib/bodyEffects'; +import { debugBlobbi } from './lib/debug'; +import type { Blobbi } from '@/blobbi/core/types/blobbi'; + +export interface BlobbiBabySvgRendererProps { + /** The Blobbi data */ + blobbi: Blobbi; + /** Whether the Blobbi is sleeping */ + isSleeping: boolean; + /** Pre-resolved visual recipe. Takes precedence over `emotion`. */ + recipe?: BlobbiVisualRecipe; + /** Label for the recipe (used in CSS class names). */ + recipeLabel?: string; + /** Named emotion preset. Ignored when `recipe` is provided. Default: 'neutral' */ + emotion?: BlobbiEmotion; + /** Body-level visual effects (manual/external use only — not from status reaction). */ + bodyEffects?: BodyEffectsSpec; + /** Additional CSS classes for the container */ + className?: string; +} + +/** + * Pure SVG renderer for baby Blobbi. + */ +export function BlobbiBabySvgRenderer({ + blobbi, + isSleeping, + recipe: recipeProp, + recipeLabel, + emotion = 'neutral', + bodyEffects, + className, +}: BlobbiBabySvgRendererProps) { + const containerRef = useRef(null); + + const customizedSvg = useMemo(() => { + debugBlobbi('svg-rebuild', 'baby customizedSvg rebuild'); + + const baseSvg = resolveBabySvg(blobbi, { isSleeping }); + const colorizedSvg = customizeBabySvgFromBlobbi(baseSvg, blobbi, isSleeping); + + if (!isSleeping) { + let animatedSvg = addEyeAnimation(colorizedSvg, { baseColor: blobbi.baseColor, instanceId: blobbi.id }); + + if (recipeProp) { + animatedSvg = applyVisualRecipe(animatedSvg, recipeProp, recipeLabel ?? 'status', 'baby', undefined, blobbi.id); + } else if (emotion !== 'neutral') { + const resolved = resolveVisualRecipe(emotion); + animatedSvg = applyVisualRecipe(animatedSvg, resolved, emotion, 'baby', undefined, blobbi.id); + } + + if (bodyEffects && !recipeProp) { + animatedSvg = applyBodyEffects(animatedSvg, { ...bodyEffects, idPrefix: bodyEffects.idPrefix ?? blobbi.id }); + } + + return animatedSvg; + } + + return colorizedSvg; + }, [blobbi, isSleeping, recipeProp, recipeLabel, emotion, bodyEffects]); + + const safeSvg = useMemo(() => sanitizeBlobbiSvg(customizedSvg), [customizedSvg]); + + return ( +
+ ); +} diff --git a/src/blobbi/ui/BlobbiBabyVisual.tsx b/src/blobbi/ui/BlobbiBabyVisual.tsx index 1d1e365f..cd7e4bcd 100644 --- a/src/blobbi/ui/BlobbiBabyVisual.tsx +++ b/src/blobbi/ui/BlobbiBabyVisual.tsx @@ -1,112 +1,82 @@ /** - * BlobbiBabyVisual - Reusable component for rendering Blobbi babies + * BlobbiBabyVisual — Visual wrapper for rendering Blobbi babies. * - * Uses the baby-blobbi module for SVG resolution and customization. - * Handles awake vs sleeping states automatically. - * Eyes always track the mouse cursor in real-time. + * Responsibilities: + * - Owns the container ref for eye hooks to query SVG DOM + * - Runs useBlobbiEyes (blink RAF loop, optional mouse tracking) + * - Runs useExternalEyeOffset (companion gaze RAF loop) + * - Applies reaction CSS classes (sway/bounce) in page mode + * - Delegates SVG rendering to BlobbiBabySvgRenderer * - * Accepts either: - * - `recipe` + `recipeLabel`: a pre-resolved visual recipe (recipe-first path - * from useStatusReaction). The recipe includes body effects — no separate - * bodyEffects prop is needed for this path. - * - `emotion`: a named emotion preset (convenience path, resolved internally) - * - * An optional `bodyEffects` prop is available for manual/external use cases - * outside the status reaction system. + * Render modes: + * - 'page' (default): Mouse tracking enabled, reaction classes applied here. + * - 'companion': Mouse tracking disabled (gaze via ref), reaction classes + * suppressed (applied by outer companion wrapper instead). */ -import { useMemo, useRef, useEffect, type RefObject } from 'react'; +import { useRef, type RefObject } from 'react'; + +import { cn } from '@/lib/utils'; -import { resolveBabySvg, customizeBabySvgFromBlobbi } from '@/blobbi/baby-blobbi'; -import { addEyeAnimation } from './lib/eye-animation'; -import { resolveVisualRecipe, applyVisualRecipe, type BlobbiVisualRecipe } from './lib/recipe'; -import type { BlobbiEmotion } from './lib/emotion-types'; -import { applyBodyEffects, type BodyEffectsSpec } from './lib/bodyEffects'; import { useBlobbiEyes, type BlobbiLookMode } from './lib/useBlobbiEyes'; import { useExternalEyeOffset } from './lib/useExternalEyeOffset'; -import type { ExternalEyeOffset, BlobbiReactionState } from './lib/types'; -import { cn } from '@/lib/utils'; +import type { ExternalEyeOffset, BlobbiReactionState, BlobbiRenderMode } from './lib/types'; +import type { BlobbiVisualRecipe } from './lib/recipe'; +import type { BlobbiEmotion } from './lib/emotion-types'; +import type { BodyEffectsSpec } from './lib/bodyEffects'; import type { Blobbi } from '@/blobbi/core/types/blobbi'; import { isBlobbiSleeping } from '@/blobbi/core/types/blobbi'; -import { sanitizeBlobbiSvg } from '@/lib/sanitizeBlobbiSvg'; +import { BlobbiBabySvgRenderer } from './BlobbiBabySvgRenderer'; // Re-export types for backwards compatibility export type { ExternalEyeOffset }; -/** - * @deprecated Use BlobbiReactionState from './lib/types' instead - */ +/** @deprecated Use BlobbiReactionState from './lib/types' instead */ export type BabyReactionState = BlobbiReactionState; export interface BlobbiBabyVisualProps { blobbi: Blobbi; - reaction?: BabyReactionState; + reaction?: BlobbiReactionState; lookMode?: BlobbiLookMode; disableBlink?: boolean; externalEyeOffset?: ExternalEyeOffset; /** Ref-based external eye offset (imperative — no rerenders). Preferred for companion mode. */ externalEyeOffsetRef?: RefObject; + /** Render mode. Default: 'page'. */ + renderMode?: BlobbiRenderMode; /** Pre-resolved visual recipe. Takes precedence over `emotion`. */ recipe?: BlobbiVisualRecipe; - /** Label for the recipe (CSS class names). Required when `recipe` is provided. */ + /** Label for the recipe (CSS class names). */ recipeLabel?: string; - /** Named emotion preset (convenience path). Ignored when `recipe` is provided. */ + /** Named emotion preset. Ignored when `recipe` is provided. */ emotion?: BlobbiEmotion; - /** - * Body-level visual effects — for manual/external use only. - * Status-reaction body effects are already in the recipe. - */ + /** Body-level visual effects — for manual/external use only. */ bodyEffects?: BodyEffectsSpec; className?: string; } -// ─── Component ──────────────────────────────────────────────────────────────── - -// ─── DEBUG: Animation lifecycle instrumentation ────────────────────────────── -const _babySvgRebuildCount = { current: 0 }; -const _babySafeSvgCount = { current: 0 }; -const _babyRenderCount = { current: 0 }; -const _babyPrevProps = { current: null as Record | null }; -// ────────────────────────────────────────────────────────────────────────────── - -export function BlobbiBabyVisual({ blobbi, reaction = 'idle', lookMode = 'follow-pointer', disableBlink = false, externalEyeOffset, externalEyeOffsetRef, recipe: recipeProp, recipeLabel, emotion = 'neutral', bodyEffects, className }: BlobbiBabyVisualProps) { +export function BlobbiBabyVisual({ + blobbi, + reaction = 'idle', + lookMode = 'follow-pointer', + disableBlink = false, + externalEyeOffset, + externalEyeOffsetRef, + renderMode = 'page', + recipe, + recipeLabel, + emotion = 'neutral', + bodyEffects, + className, +}: BlobbiBabyVisualProps) { const isSleeping = isBlobbiSleeping(blobbi); const containerRef = useRef(null); - - // ─── DEBUG: Track renders and prop changes ─────────────────────────────── - _babyRenderCount.current++; - const isCompanion = !!(externalEyeOffset || externalEyeOffsetRef); - if (isCompanion) { - const currentProps: Record = { - blobbiId: blobbi.id, - blobbiRef: blobbi, - isSleeping, - recipeProp, - recipeLabel, - emotion, - bodyEffects, - }; - const prev = _babyPrevProps.current; - if (prev) { - const changed: string[] = []; - for (const key of Object.keys(currentProps)) { - if (currentProps[key] !== prev[key]) { - changed.push(key); - } - } - if (changed.length > 0) { - console.log(`%c[BabyVisual] COMPANION render #${_babyRenderCount.current} — props changed: ${changed.join(', ')}`, 'color: #f59e0b; font-weight: bold'); - for (const key of changed) { - console.log(` ${key}: `, prev[key], ' → ', currentProps[key]); - } - } - } - _babyPrevProps.current = currentProps; - } - // ───────────────────────────────────────────────────────────────────────── + const isCompanion = renderMode === 'companion'; const effectiveReaction = isSleeping ? 'idle' : reaction; + // ── Eye hooks ────────────────────────────────────────────────────────────── + useBlobbiEyes(containerRef, { isSleeping, maxMovement: 2, @@ -123,71 +93,7 @@ export function BlobbiBabyVisual({ blobbi, reaction = 'idle', lookMode = 'follow variant: 'baby', }); - const customizedSvg = useMemo(() => { - // ─── DEBUG: Track SVG rebuilds ────────────────────────────────────── - if (isCompanion) { - _babySvgRebuildCount.current++; - console.log(`%c[BabyVisual] COMPANION customizedSvg rebuild #${_babySvgRebuildCount.current}`, 'color: #ef4444; font-weight: bold'); - console.trace('[BabyVisual] SVG rebuild stack trace'); - } - // ──────────────────────────────────────────────────────────────────── - - const baseSvg = resolveBabySvg(blobbi, { isSleeping }); - const colorizedSvg = customizeBabySvgFromBlobbi(baseSvg, blobbi, isSleeping); - - if (!isSleeping) { - let animatedSvg = addEyeAnimation(colorizedSvg, { baseColor: blobbi.baseColor, instanceId: blobbi.id }); - - // Recipe-first path: applyVisualRecipe() handles body effects in the recipe. - if (recipeProp) { - animatedSvg = applyVisualRecipe(animatedSvg, recipeProp, recipeLabel ?? 'status', 'baby', undefined, blobbi.id); - } else if (emotion !== 'neutral') { - const resolved = resolveVisualRecipe(emotion); - animatedSvg = applyVisualRecipe(animatedSvg, resolved, emotion, 'baby', undefined, blobbi.id); - } - - // Manual body effects prop — only when no recipe was provided. - if (bodyEffects && !recipeProp) { - animatedSvg = applyBodyEffects(animatedSvg, { ...bodyEffects, idPrefix: bodyEffects.idPrefix ?? blobbi.id }); - } - - return animatedSvg; - } - - return colorizedSvg; - }, [blobbi, isSleeping, recipeProp, recipeLabel, emotion, bodyEffects]); - - const safeSvg = useMemo(() => { - // ─── DEBUG: Track sanitization rebuilds ────────────────────────────── - if (isCompanion) { - _babySafeSvgCount.current++; - console.log(`%c[BabyVisual] COMPANION safeSvg rebuild #${_babySafeSvgCount.current}`, 'color: #ef4444'); - } - // ──────────────────────────────────────────────────────────────────── - return sanitizeBlobbiSvg(customizedSvg); - }, [customizedSvg]); - - // ─── DEBUG: Track DOM node identity (detect remounts vs rerenders) ────── - const prevSvgNodeRef = useRef(null); - useEffect(() => { - if (!isCompanion || !containerRef.current) return; - const svgNode = containerRef.current.querySelector('svg'); - if (svgNode && svgNode !== prevSvgNodeRef.current) { - if (prevSvgNodeRef.current === null) { - console.log('%c[BabyVisual] COMPANION: SVG node mounted (first time)', 'color: #22c55e'); - } else { - console.log('%c[BabyVisual] COMPANION: SVG DOM NODE REPLACED! (animations killed)', 'color: #ef4444; font-weight: bold; font-size: 14px'); - } - prevSvgNodeRef.current = svgNode; - const animates = svgNode.querySelectorAll('animate, animateTransform'); - console.log(` SVG has ${animates.length} SMIL animation elements`); - } - }); - // ───────────────────────────────────────────────────────────────────────── - - // In companion mode, reaction CSS classes are applied by the outer wrapper - // (BlobbiCompanionVisual) so this div stays className-stable. - const applyReactionClasses = !isCompanion; + // ── Render ───────────────────────────────────────────────────────────────── return (
+ > + +
); } diff --git a/src/blobbi/ui/lib/debug.ts b/src/blobbi/ui/lib/debug.ts new file mode 100644 index 00000000..5422ab85 --- /dev/null +++ b/src/blobbi/ui/lib/debug.ts @@ -0,0 +1,44 @@ +/** + * Centralized debug logging for Blobbi visual system. + * + * All Blobbi debug logging should go through this helper. + * Only logs when BOTH conditions are met: + * 1. Running in development mode (import.meta.env.DEV) + * 2. BLOBBI_DEBUG flag is enabled + * + * To enable: set BLOBBI_DEBUG = true below. + * To disable: set BLOBBI_DEBUG = false (default for production-clean console). + */ + +/** Master switch for Blobbi visual debug logging. */ +const BLOBBI_DEBUG = false; + +type DebugCategory = + | 'svg-rebuild' // SVG pipeline rebuilds (customizedSvg / safeSvg) + | 'dom-replace' // SVG DOM node was replaced (animation killer) + | 'dom-mount' // SVG DOM node mounted for first time + | 'prop-change' // Props changed on a visual component + | 'ref-change' // Object reference changed (companion, blobbi, recipe) + | 'render-freq' // Render frequency tracking + | 'smil' // SMIL animation element counts + | 'recipe' // Recipe resolution and stability + | 'general'; // Catch-all + +/** + * Log a Blobbi debug message. + * + * @param category - Debug category for filtering + * @param args - Arguments forwarded to console.log + */ +export function debugBlobbi(category: DebugCategory, ...args: unknown[]): void { + if (!import.meta.env.DEV || !BLOBBI_DEBUG) return; + console.log(`[blobbi:${category}]`, ...args); +} + +/** + * Log a Blobbi debug warning (always styled as warning). + */ +export function debugBlobbiWarn(category: DebugCategory, ...args: unknown[]): void { + if (!import.meta.env.DEV || !BLOBBI_DEBUG) return; + console.warn(`[blobbi:${category}]`, ...args); +} diff --git a/src/blobbi/ui/lib/types.ts b/src/blobbi/ui/lib/types.ts index bf33955f..4c0ed9d2 100644 --- a/src/blobbi/ui/lib/types.ts +++ b/src/blobbi/ui/lib/types.ts @@ -11,6 +11,21 @@ * eliminating duplicate definitions across the codebase. */ +// ─── Render Mode ────────────────────────────────────────────────────────────── + +/** + * Controls how the Blobbi visual is rendered. + * + * - 'page': Default. Runs eye tracking hooks internally, applies reaction + * CSS classes on the SVG container. Used by BlobbiStageVisual / BlobbiPage. + * + * - 'companion': Optimized for the floating companion runtime. Disables + * internal mouse tracking (gaze driven by ref), suppresses reaction CSS + * classes on the SVG container (applied by outer wrapper instead) to + * keep the dangerouslySetInnerHTML node stable. + */ +export type BlobbiRenderMode = 'page' | 'companion'; + // ─── Eye Tracking Types ─────────────────────────────────────────────────────── /**