refactor(blobbi): extract pure SVG renderers and add explicit render mode

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.
This commit is contained in:
filemon
2026-03-31 03:53:17 -03:00
parent 81966dac0d
commit 384936f106
8 changed files with 422 additions and 480 deletions
@@ -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<HTMLDivElement>(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,
@@ -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<EyeOffset>;
/** 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 (
<BlobbiBabyVisual
blobbi={blobbi}
renderMode="companion"
lookMode="forward"
externalEyeOffsetRef={eyeOffsetRef}
recipe={recipe}
@@ -113,6 +88,7 @@ const MemoizedBlobbiVisual = memo(function MemoizedBlobbiVisual({
return (
<BlobbiAdultVisual
blobbi={blobbi}
renderMode="companion"
lookMode="forward"
externalEyeOffsetRef={eyeOffsetRef}
recipe={recipe}
@@ -123,7 +99,6 @@ const MemoizedBlobbiVisual = memo(function MemoizedBlobbiVisual({
/>
);
}, (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<typeof companionDataToBlobbi> | null };
// ──────────────────────────────────────────────────────────────────────────────
// ─── Component ────────────────────────────────────────────────────────────────
export function BlobbiCompanionVisual({
companion,
@@ -159,137 +129,69 @@ export function BlobbiCompanionVisual({
debugMode = false,
}: BlobbiCompanionVisualProps) {
const containerRef = useRef<HTMLDivElement>(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 (
<div
<div
ref={containerRef}
className={cn('relative', className)}
style={{ width: size, height: size }}
>
{/* DEBUG: Container and alignment markers */}
{/* Debug alignment markers */}
{debugMode && (
<>
{/* Container outline - lime */}
<div
className="absolute inset-0 pointer-events-none"
style={{
border: '2px solid lime',
boxSizing: 'border-box',
}}
/>
{/* 88% line from top (where SVG body bottom should be before shift) - yellow */}
<div
className="absolute pointer-events-none"
style={{
top: `${size * 0.88}px`,
left: 0,
right: 0,
height: 2,
backgroundColor: 'yellow',
}}
/>
{/* 100% line (container bottom where body should touch after shift) - cyan */}
<div
className="absolute pointer-events-none"
style={{
bottom: 0,
left: 0,
right: 0,
height: 2,
backgroundColor: 'cyan',
}}
/>
{/* Label showing the expected shift */}
<div
className="absolute pointer-events-none"
style={{
top: 2,
left: 2,
fontSize: 8,
color: 'white',
backgroundColor: 'black',
padding: '1px 2px',
}}
>
<div className="absolute inset-0 pointer-events-none" style={{ border: '2px solid lime', boxSizing: 'border-box' }} />
<div className="absolute pointer-events-none" style={{ top: `${size * 0.88}px`, left: 0, right: 0, height: 2, backgroundColor: 'yellow' }} />
<div className="absolute pointer-events-none" style={{ bottom: 0, left: 0, right: 0, height: 2, backgroundColor: 'cyan' }} />
<div className="absolute pointer-events-none" style={{ top: 2, left: 2, fontSize: 8, color: 'white', backgroundColor: 'black', padding: '1px 2px' }}>
shift: {size * 0.12}px
</div>
</>
)}
{/* 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 && (
<div
className="absolute pointer-events-none"
style={{
// Position shadow well below Blobbi to feel like it's on the floor
bottom: -20,
left: '50%',
width: size * 0.5,
@@ -298,40 +200,28 @@ export function BlobbiCompanionVisual({
background: `radial-gradient(ellipse at center, rgba(0,0,0,${shadowOpacity}) 0%, rgba(0,0,0,${shadowOpacity * 0.5}) 40%, transparent 70%)`,
borderRadius: '50%',
filter: 'blur(4px)',
opacity: groundFadeRatio, // Additional opacity control for smooth fade
opacity: groundFadeRatio,
transition: 'opacity 0.15s ease-out, transform 0.1s ease-out',
}}
/>
)}
{/* 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 */}
<div
className={cn(
'size-full',
// Reaction CSS animations applied HERE (outer wrapper), not on the SVG container.
// This prevents className changes from triggering dangerouslySetInnerHTML replacement.
// Companion reactions: 'swaying' (walking), 'happy' (dragging), 'idle' (default)
(reaction === 'swaying' || reaction === 'happy') && 'animate-blobbi-sway',
)}
style={{
// First apply the SVG alignment correction, then the float animation
// The 12% shift pushes the SVG down so its visible body bottom aligns with container bottom
transform: [
`translateY(${size * 0.12}px)`, // SVG body alignment correction
blobbiTransform, // Float animation (if any)
`translateY(${size * 0.12}px)`,
blobbiTransform,
].filter(Boolean).join(' ') || undefined,
transformOrigin: 'center bottom',
transition: isDragging ? 'none' : 'transform 0.05s ease-out',
// DEBUG: Show the shifted wrapper
...(debugMode ? { outline: '2px dashed magenta' } : {}),
}}
>
{/* Memoized visual: only rerenders when visual inputs change */}
{(companion.stage === 'baby' || companion.stage === 'adult') && (
<MemoizedBlobbiVisual
stage={companion.stage}
+105
View File
@@ -0,0 +1,105 @@
/**
* BlobbiAdultSvgRenderer — Pure SVG rendering component for adult Blobbi.
*
* This component is the leaf node of the visual pipeline. It:
* 1. Resolves the base SVG for the adult form
* 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)
*
* This separation ensures that the SVG DOM node stays mounted and stable
* as long as the visual inputs don't change. SMIL and CSS animations
* inside the SVG continue running across parent rerenders.
*/
import { useMemo, useRef } from 'react';
import { resolveAdultSvgWithForm, customizeAdultSvgFromBlobbi } from '@/blobbi/adult-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 BlobbiAdultSvgRendererProps {
/** 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 adult Blobbi.
*
* The containerRef is forwarded via the returned div so that parent
* components can attach eye-tracking hooks to query the SVG DOM.
*/
export function BlobbiAdultSvgRenderer({
blobbi,
isSleeping,
recipe: recipeProp,
recipeLabel,
emotion = 'neutral',
bodyEffects,
className,
}: BlobbiAdultSvgRendererProps) {
const containerRef = useRef<HTMLDivElement>(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 (
<div
ref={containerRef}
className={className}
dangerouslySetInnerHTML={{ __html: safeSvg }}
/>
);
}
+64 -174
View File
@@ -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<ExternalEyeOffset>;
/**
* 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<string, unknown> | 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<HTMLDivElement>(null);
const isCompanion = renderMode === 'companion';
// ─── DEBUG: Track renders and prop changes ───────────────────────────────
_adultRenderCount.current++;
const isCompanion = !!(externalEyeOffset || externalEyeOffsetRef);
if (isCompanion) {
const currentProps: Record<string, unknown> = {
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<Element | null>(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 (
<div
@@ -232,14 +113,23 @@ export function BlobbiAdultVisual({ blobbi, reaction = 'idle', lookMode = 'follo
className={cn(
'relative flex items-center justify-center',
isSleeping && 'opacity-70',
applyReactionClasses && (effectiveReaction === 'listening' ||
!isCompanion && (effectiveReaction === 'listening' ||
effectiveReaction === 'swaying' ||
effectiveReaction === 'happy') &&
'animate-blobbi-sway',
applyReactionClasses && effectiveReaction === 'singing' && 'animate-blobbi-bounce',
className
!isCompanion && effectiveReaction === 'singing' && 'animate-blobbi-bounce',
className,
)}
dangerouslySetInnerHTML={{ __html: safeSvg }}
/>
>
<BlobbiAdultSvgRenderer
blobbi={blobbi}
isSleeping={isSleeping}
recipe={recipe}
recipeLabel={recipeLabel}
emotion={emotion}
bodyEffects={bodyEffects}
className="size-full"
/>
</div>
);
}
+98
View File
@@ -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<HTMLDivElement>(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 (
<div
ref={containerRef}
className={className}
dangerouslySetInnerHTML={{ __html: safeSvg }}
/>
);
}
+58 -143
View File
@@ -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<ExternalEyeOffset>;
/** 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<string, unknown> | 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<HTMLDivElement>(null);
// ─── DEBUG: Track renders and prop changes ───────────────────────────────
_babyRenderCount.current++;
const isCompanion = !!(externalEyeOffset || externalEyeOffsetRef);
if (isCompanion) {
const currentProps: Record<string, unknown> = {
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<Element | null>(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 (
<div
@@ -195,14 +101,23 @@ export function BlobbiBabyVisual({ blobbi, reaction = 'idle', lookMode = 'follow
className={cn(
'relative flex items-center justify-center',
isSleeping && 'opacity-70',
applyReactionClasses && (effectiveReaction === 'listening' ||
!isCompanion && (effectiveReaction === 'listening' ||
effectiveReaction === 'swaying' ||
effectiveReaction === 'happy') &&
'animate-blobbi-sway',
applyReactionClasses && effectiveReaction === 'singing' && 'animate-blobbi-bounce',
className
!isCompanion && effectiveReaction === 'singing' && 'animate-blobbi-bounce',
className,
)}
dangerouslySetInnerHTML={{ __html: safeSvg }}
/>
>
<BlobbiBabySvgRenderer
blobbi={blobbi}
isSleeping={isSleeping}
recipe={recipe}
recipeLabel={recipeLabel}
emotion={emotion}
bodyEffects={bodyEffects}
className="size-full"
/>
</div>
);
}
+44
View File
@@ -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);
}
+15
View File
@@ -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 ───────────────────────────────────────────────────────
/**