From aadd2908e2dfd290b80bcd3983944a7bfb3a3dbf Mon Sep 17 00:00:00 2001 From: filemon Date: Sat, 11 Apr 2026 19:52:21 -0300 Subject: [PATCH] Add generic route-transition reaction for Blobbi companion On page navigation the companion now briefly pauses and scans the layout areas that changed. Center content is always scanned first, followed by the right sidebar if a non-placeholder sidebar is detected in the DOM. Implementation: - useRouteReaction.ts: thin orchestration hook that watches pathname, determines changed areas, and chains triggerAttention calls via setTimeout. Cancels on new route change, drag, or unmount. - useBlobbiCompanion.ts: wires the new hook with existing triggerAttention/clearAttention from useBlobbiAttention. No changes to the attention system, state machine, gaze hook, motion hook, or entry animation. The existing attending state and attend-ui gaze mode handle all the visual behavior. Includes an empty ROUTE_REACTIONS map for future per-route overrides. --- .../companion/hooks/useBlobbiCompanion.ts | 14 +- .../companion/hooks/useRouteReaction.ts | 283 ++++++++++++++++++ 2 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 src/blobbi/companion/hooks/useRouteReaction.ts 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: