diff --git a/src/blobbi/companion/hooks/useBlobbiCompanion.ts b/src/blobbi/companion/hooks/useBlobbiCompanion.ts index 8dd1f2cc..fc055f95 100644 --- a/src/blobbi/companion/hooks/useBlobbiCompanion.ts +++ b/src/blobbi/companion/hooks/useBlobbiCompanion.ts @@ -36,6 +36,7 @@ import { useBlobbiCompanionMotion } from './useBlobbiCompanionMotion'; import { useBlobbiCompanionGaze } from './useBlobbiCompanionGaze'; import { useBlobbiAttention } from './useBlobbiAttention'; import { useBlobbiEntryAnimation } from './useBlobbiEntryAnimation'; +import { useRouteReaction } from './useRouteReaction'; import { useFeedSettings } from '@/hooks/useFeedSettings'; /** Options for triggering attention */ @@ -189,7 +190,7 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult { }, [viewport.width, viewport.height]); // Attention management - will be activated after entry completes - const { currentAttention, triggerAttention } = useBlobbiAttention({ + const { currentAttention, triggerAttention, clearAttention } = useBlobbiAttention({ isActive: isVisible && hasEnteredOnce, }); @@ -275,6 +276,17 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult { onStart: handleEntryStart, }); + // Route-change reactions — scan changed layout areas after navigation + useRouteReaction({ + pathname: location.pathname, + triggerAttention, + clearAttention, + hasEnteredOnce, + isEntering, + isDragging: motion.isDragging, + viewport, + }); + // Companion should be hidden during route transition delay const shouldBeVisible = isVisible && !isHiddenForTransition; diff --git a/src/blobbi/companion/hooks/useRouteReaction.ts b/src/blobbi/companion/hooks/useRouteReaction.ts new file mode 100644 index 00000000..599318d0 --- /dev/null +++ b/src/blobbi/companion/hooks/useRouteReaction.ts @@ -0,0 +1,283 @@ +/** + * useRouteReaction Hook + * + * Thin orchestration layer for page-transition reactions. + * + * On route change the companion briefly pauses and scans the layout areas + * that changed (center content first, then right sidebar if present), + * using the existing attention system for each step. + * + * Architecture: + * - Watches pathname for changes (after the initial entry has completed) + * - Determines which layout columns changed + * - Fires a sequence of triggerAttention calls with setTimeout chaining + * - Cancels the sequence on new route change, drag, or higher-priority attention + * + * Future custom reactions: + * Add entries to ROUTE_REACTIONS to override the generic scan for specific + * routes. Each entry receives a context object with triggerAttention and a + * timeouts array (auto-cancelled on next route change). + */ + +import { useEffect, useRef, useCallback } from 'react'; + +import type { Position, AttentionPriority } from '../types/companion.types'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface TriggerAttentionFn { + (position: Position, options?: { + duration?: number; + priority?: AttentionPriority; + source?: string; + isGlance?: boolean; + }): void; +} + +interface UseRouteReactionOptions { + /** Current pathname from useLocation */ + pathname: string; + /** Trigger a single attention target */ + triggerAttention: TriggerAttentionFn; + /** Clear any current attention */ + clearAttention: () => void; + /** Whether the companion has completed its first entry */ + hasEnteredOnce: boolean; + /** Whether entry animation is currently playing */ + isEntering: boolean; + /** Whether the companion is being dragged */ + isDragging: boolean; + /** Current viewport dimensions */ + viewport: { width: number; height: number }; +} + +/** Context passed to custom route-reaction functions. */ +interface RouteReactionContext { + pathname: string; + prevPathname: string; + triggerAttention: TriggerAttentionFn; + clearAttention: () => void; + viewport: { width: number; height: number }; + /** Push timeout IDs here — they are auto-cancelled on next route change. */ + timeouts: ReturnType[]; +} + +type RouteReactionFn = (ctx: RouteReactionContext) => void; + +// ─── Custom Route Reaction Map ──────────────────────────────────────────────── +// +// Add entries here to override the generic scan for specific routes. +// +// Example (not implemented yet): +// '/treasures': (ctx) => { /* special treasure-chest reaction */ }, +// '/blobbi': (ctx) => { /* special blobbi-page reaction */ }, +// + +const ROUTE_REACTIONS: Record = { + // intentionally empty — generic fallback handles all routes for now +}; + +// ─── Timing ─────────────────────────────────────────────────────────────────── + +/** Duration per area in the generic scan sequence (ms) */ +const SCAN_STEP_DURATION = 1200; + +/** Delay before starting the reaction after route change (ms). + * Gives the new page's DOM time to mount. */ +const ROUTE_REACTION_DELAY = 250; + +// ─── Layout helpers ─────────────────────────────────────────────────────────── + +/** Find the center-top of the main content column. */ +function findMainContentPosition(viewport: { width: number; height: number }): Position | null { + const selectors = [ + 'main', + '[role="main"]', + '.main-content', + '#main-content', + ]; + + for (const sel of selectors) { + const el = document.querySelector(sel); + if (el) { + const rect = el.getBoundingClientRect(); + return { + x: rect.left + rect.width / 2, + y: rect.top + Math.min(rect.height * 0.3, 200), + }; + } + } + + // Fallback: center-top of viewport + return { x: viewport.width / 2, y: viewport.height * 0.25 }; +} + +/** + * Find the center-top of a visible right sidebar. + * + * Returns null when: + * - The sidebar is the empty 300px placeholder (no meaningful content) + * - The sidebar is not visible (below xl breakpoint) + * - No sidebar element is found at all + * + * Detection is conservative: we look for the element that MainLayout renders + * *after* the center column, check that it is visible and has children beyond + * a single empty placeholder div. + */ +function findRightSidebarPosition(): Position | null { + // MainLayout renders:
→ LeftSidebar | CenterColumn | RightSidebar + // The right sidebar is the last child of the flex container. + // When a page provides a custom sidebar, it replaces the placeholder entirely. + // The placeholder is: