/** * @file CatAvatar.tsx * @description Pure presentational SVG cat - Tabby. Given a mood it renders the * matching expression (ears, eyes, cheeks, mouth, tail, paws) via a `data-mood` * attribute that drives the CSS in tabby.css. When motion is allowed, the * pupils track the cursor. No data access - fully testable / reusable in * isolation. Geometry tuned for max cuteness: big round head, oversized * sparkly eyes, pink ear-insides + cheek blush, classic tabby forehead * stripes, a fluffy tail, and little paws peeking at the bottom. * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) * ============================================================================= * **Purpose:** Tabby is the optional on-screen cat assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome. * * ## Design constraints * - Local-first: no telemetry leaves the machine unless the user configures webhooks. * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). * - Destructive flows stay behind explicit confirmation modals and server-side gates. * - Internationalization: user-visible strings belong in i18n JSON, not literals here. * * ## Remote data & SSH * Remote Data Sources let operators aggregate multiple machines. SSH entries describe * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every * scoped GET via `?sources=`. Health checks and import history surface in Settings. * * ## Observability * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and * Docker Compose profiles are documented in `monitoring/README.md`. * * ## Internal dependencies * - `./brain` * * ## Public surface * - `CatAvatar` — exported API; see TSDoc on the symbol for behavior. * * ## Testing pointers * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. * - Server contract changes require `npm run test:server` and OpenAPI sync. * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `.claude/skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ /* ----------------------------------------------------------------------------- * EXPORT CATALOG — quick index of symbols defined below (documentation only). * ----------------------------------------------------------------------------- * **CatAvatar** * Part of this module's public contract. Downstream imports should treat * the signature and return type as stable unless release notes say otherwise. * When behavior changes, update the `@file` overview and relevant tests. * * ----------------------------------------------------------------------------- */ import { useEffect, useRef, useState } from "react"; import type { Mood } from "./brain"; interface CatAvatarProps { mood: Mood; reducedMotion: boolean; size?: number; } const MAX_PUPIL_SHIFT = 2.8; // px in the 100x100 viewBox // Module-level last-known cursor position, tracked from the moment this module // first loads (well before any avatar mounts). This is what makes eye tracking // feel *immediate*: as soon as the cat mounts it aims at wherever the cursor // already is, instead of sitting centered until the next mousemove. `null` // until the very first pointer event of the page's life. let lastCursor: { x: number; y: number } | null = null; if (typeof window !== "undefined") { const remember = (e: MouseEvent) => { lastCursor = { x: e.clientX, y: e.clientY }; }; // capture phase + passive so we never interfere with anything else. window.addEventListener("mousemove", remember, { capture: true, passive: true }); window.addEventListener("pointermove", remember as EventListener, { capture: true, passive: true, }); } export function CatAvatar({ mood, reducedMotion, size = 60 }: CatAvatarProps) { const rootRef = useRef(null); const rafRef = useRef(); const [pupil, setPupil] = useState({ x: 0, y: 0 }); // Pupils follow the cursor whenever motion is allowed and the eyes are open. // (Only `sleeping` closes them by intent; `disconnected` hides the open-eye // group via CSS, so tracking there is harmless and keeps eyes pre-aimed for // the instant the connection returns.) const tracking = !reducedMotion && mood !== "sleeping"; useEffect(() => { if (!tracking) { setPupil({ x: 0, y: 0 }); return; } const aimAt = (clientX: number, clientY: number) => { const el = rootRef.current; if (!el) return; const r = el.getBoundingClientRect(); const cx = r.left + r.width / 2; const cy = r.top + r.height / 2; const dx = clientX - cx; const dy = clientY - cy; const dist = Math.hypot(dx, dy) || 1; // Normalize then clamp to the eye socket range. const nx = (dx / dist) * Math.min(1, dist / 240); const ny = (dy / dist) * Math.min(1, dist / 240); setPupil({ x: nx * MAX_PUPIL_SHIFT, y: ny * MAX_PUPIL_SHIFT }); }; // Immediately aim at the last-known cursor (next frame, so layout is ready) // - no waiting for the user to move the mouse first. let initRaf = 0; if (lastCursor) { const c = lastCursor; initRaf = requestAnimationFrame(() => aimAt(c.x, c.y)); } const onMove = (e: MouseEvent) => { if (rafRef.current) return; rafRef.current = requestAnimationFrame(() => { rafRef.current = undefined; aimAt(e.clientX, e.clientY); }); }; window.addEventListener("mousemove", onMove, { passive: true }); return () => { window.removeEventListener("mousemove", onMove); if (initRaf) cancelAnimationFrame(initRaf); if (rafRef.current) cancelAnimationFrame(rafRef.current); rafRef.current = undefined; }; }, [tracking]); return ( {/* Soft top-lit gradient for the body/head - gives a rounded, plush feel. */} {/* soft glow halo */} {/* tail - fluffy curl to the right */} {/* body / chest peeking up from the bottom */} {/* paws */} {/* ears - rounded, with pink inner */} {/* head - big and round */} {/* classic tabby forehead stripes */} {/* cheek blush */} {/* eyes - open state (big + sparkly) */} {/* Outer group carries the eye-tracking translate; the inner group carries the blink (scaleY) animation. They MUST be separate elements - a CSS animation on `transform` overrides an inline `transform`, so putting both on one node makes the blink clobber the tracking (the original "eyes only track after a while" bug). */} {/* eyes - happy (^ ^) */} {/* eyes - closed (sleeping / offline) */} {/* worried brows */} {/* nose - tiny heart */} {/* mouths */} {/* whiskers */} {/* zzz for sleeping */} z z {/* alert bang for stuck */} ! {/* sparkle for happy */} ); }