/** * @file SagiAvatar.tsx * @description Pure presentational SVG mascot - Sagi, a blue superhero pig. Traced * from the user-provided mascot.svg (186 anonymous paths, no part grouping), so * only the eyes could be safely extracted into their own groups; everything else * renders as the original flat artwork. Eye pupils track the cursor and blink via * nested `sagi-eye-track` / `sagi-eye-blink` groups. Mood is expressed via a * `data-mood` attribute driving whole-mascot CSS transforms (bob/shake/tilt/breathe) * in sagi.css, plus zzz/bang/sparkle overlay glyphs layered above the artwork. * No data access - fully testable / reusable in isolation. * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) * ============================================================================= * **Purpose:** Sagi is the optional on-screen mascot 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. * * ## Internal dependencies * - `./brain` * * ## Public surface * - `SagiAvatar` — exported API; see TSDoc on the symbol for behavior. * * ## Testing pointers * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ import { memo, useEffect, useRef } from "react"; import type { Mood } from "./brain"; interface SagiAvatarProps { mood: Mood; reducedMotion: boolean; size?: number; } const MAX_PUPIL_SHIFT = 36; // px in the 1280-unit path coordinate space // Tight window on the artwork instead of the exporter's `0 0 1280 1280` canvas, // which padded the mascot with ~330 units of empty space on the left and // ~170-240 on the other three sides. Measured bbox over every path is // x 331..1114 / y 173..1044; the top is raised to 106 and the width padded out // to a square so the mood overlays (zzz / bang / sparkle, which sit above and // to the right of the body) stay inside and the box keeps the 1:1 aspect of the // square width/height below. Path data and every coordinate constant that // references it (MAX_PUPIL_SHIFT above, the eye/mood transform-origins in // sagi.css) are untouched — only the window moved. const VIEW_BOX = "253.5 106 938 938"; // 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 Sagi 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, }); } // The pupil offset is written as CSS variables on the root, never as state, so // following the cursor costs no React render. function setPupil(el: SVGSVGElement | null, x: number, y: number) { el?.style.setProperty("--sagi-px", `${x}px`); el?.style.setProperty("--sagi-py", `${y}px`); } // memo: Sagi re-renders on every brain tick; the avatar only on mood/size/motion. export const SagiAvatar = memo(function SagiAvatar({ mood, reducedMotion, size = 68, }: SagiAvatarProps) { const rootRef = useRef(null); const rafRef = useRef(); // 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(rootRef.current, 0, 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(el, nx * MAX_PUPIL_SHIFT, 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 ( {/* mood overlay glyphs - new additions, not extracted from mascot art */} z z ! ); });