"use client"; import { type AnimationPlaybackControls, animate, motion, type MotionValue, useInView, useMotionValue, useReducedMotion, useSpring, useTransform, } from "motion/react"; import { type PointerEvent, useCallback, useEffect, useId, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export type DeskBotHue = "sky" | "emerald" | "amber" | "violet" | "rose" | "orange" | "indigo" | "teal"; const hues: Record = { sky: "[--hue-hi:var(--color-sky-300)] [--hue:var(--color-sky-400)] [--hue-deep:var(--color-sky-600)] [--ink:var(--color-sky-950)] [--glow:var(--color-sky-200)]", emerald: "[--hue-hi:var(--color-emerald-300)] [--hue:var(--color-emerald-400)] [--hue-deep:var(--color-emerald-600)] [--ink:var(--color-emerald-950)] [--glow:var(--color-emerald-200)]", amber: "[--hue-hi:var(--color-amber-300)] [--hue:var(--color-amber-400)] [--hue-deep:var(--color-amber-600)] [--ink:var(--color-amber-950)] [--glow:var(--color-amber-200)]", violet: "[--hue-hi:var(--color-violet-300)] [--hue:var(--color-violet-400)] [--hue-deep:var(--color-violet-600)] [--ink:var(--color-violet-950)] [--glow:var(--color-violet-200)]", rose: "[--hue-hi:var(--color-rose-300)] [--hue:var(--color-rose-400)] [--hue-deep:var(--color-rose-600)] [--ink:var(--color-rose-950)] [--glow:var(--color-rose-200)]", orange: "[--hue-hi:var(--color-orange-300)] [--hue:var(--color-orange-400)] [--hue-deep:var(--color-orange-600)] [--ink:var(--color-orange-950)] [--glow:var(--color-orange-200)]", indigo: "[--hue-hi:var(--color-indigo-300)] [--hue:var(--color-indigo-400)] [--hue-deep:var(--color-indigo-600)] [--ink:var(--color-indigo-950)] [--glow:var(--color-indigo-200)]", teal: "[--hue-hi:var(--color-teal-300)] [--hue:var(--color-teal-400)] [--hue-deep:var(--color-teal-600)] [--ink:var(--color-teal-950)] [--glow:var(--color-teal-200)]", }; export interface DeskBotProps { name?: string; hue?: DeskBotHue; idleAfter?: number; onWake?: () => void; onDoze?: () => void; className?: string; } const VB = { x: 20, y: 28, w: 360, h: 212 }; const FACE = { x: 200, y: 140 }; const zs = [ { size: 13, x: 270, y: 82, delay: 0 }, { size: 17, x: 286, y: 64, delay: 0.9 }, { size: 22, x: 304, y: 44, delay: 1.8 }, ]; const steam = [ { x: 76, delay: 0 }, { x: 86, delay: 1.3 }, ]; function useCalmMotion() { const prefers = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted && !!prefers; } function useBlink(active: boolean) { const open = useMotionValue(1); useEffect(() => { if (!active) { open.set(1); return; } let timer: ReturnType; let blink: AnimationPlaybackControls | undefined; const schedule = () => { timer = setTimeout( () => { const twice = Math.random() < 0.22; blink = animate(open, twice ? [1, 0.08, 1, 0.08, 1] : [1, 0.08, 1], { duration: twice ? 0.4 : 0.19, ease: "easeInOut", times: twice ? [0, 0.18, 0.5, 0.68, 1] : [0, 0.38, 1], }); schedule(); }, 2000 + Math.random() * 4200, ); }; schedule(); return () => { clearTimeout(timer); blink?.stop(); }; }, [active, open]); return open; } function Eye({ cx, near, scaleY, }: { cx: number; near: boolean; scaleY: MotionValue; }) { return ( ); } export function DeskBot({ name = "Pip", hue = "sky", idleAfter = 6000, onWake, onDoze, className }: DeskBotProps) { const uid = useId().replace(/:/g, ""); const reduce = useCalmMotion(); const root = useRef(null); const svg = useRef(null); const inView = useInView(root); const live = inView && !reduce; const [dozing, setDozing] = useState(false); const [near, setNear] = useState(false); const [tracking, setTracking] = useState(false); const [cheer, setCheer] = useState(0); const gazeX = useMotionValue(0); const gazeY = useMotionValue(0); const lookX = useSpring(gazeX, { stiffness: 260, damping: 26, mass: 0.6 }); const lookY = useSpring(gazeY, { stiffness: 260, damping: 26, mass: 0.6 }); const eyesX = useTransform(lookX, (v) => v * 13); const eyesY = useTransform(lookY, (v) => v * 8); const headTilt = useTransform(lookX, (v) => v * 4); const headShift = useTransform(lookX, (v) => v * 3); const lid = useBlink(live && !dozing); const pop = useMotionValue(1); const jolt = useMotionValue(0); const eyeScale = useTransform([lid, pop] as const, ([l, p]: number[]) => l * p); const idle = useRef>(undefined); const cheerTimer = useRef>(undefined); const callbacks = useRef({ onWake, onDoze }); useEffect(() => { callbacks.current = { onWake, onDoze }; }); const stir = useCallback(() => { clearTimeout(idle.current); idle.current = setTimeout(() => { setDozing(true); setNear(false); callbacks.current.onDoze?.(); }, idleAfter); }, [idleAfter]); useEffect(() => { if (dozing || !inView) { clearTimeout(idle.current); return; } stir(); return () => clearTimeout(idle.current); }, [dozing, inView, stir]); useEffect(() => () => clearTimeout(cheerTimer.current), []); useEffect(() => { if (dozing) { gazeX.set(0); gazeY.set(0.55); return; } if (!live || tracking) return; let timer: ReturnType; const look = () => { const centre = Math.random() < 0.35; gazeX.set(centre ? 0 : (Math.random() * 2 - 1) * 0.85); gazeY.set(centre ? 0 : (Math.random() * 2 - 1) * 0.55); timer = setTimeout(look, 1400 + Math.random() * 2400); }; timer = setTimeout(look, 700); return () => clearTimeout(timer); }, [live, tracking, dozing, gazeX, gazeY]); useEffect(() => { if (!reduce) return; gazeX.set(0); gazeY.set(dozing ? 0.55 : 0); }, [reduce, dozing, gazeX, gazeY]); const follow = (e: PointerEvent) => { if (dozing) return; stir(); if (e.pointerType === "touch" || reduce) return; const box = svg.current?.getBoundingClientRect(); if (!box?.width) return; const unit = box.width / VB.w; const dx = (e.clientX - box.left) / unit + VB.x - FACE.x; const dy = (e.clientY - box.top) / unit + VB.y - FACE.y; const reach = Math.hypot(dx, dy, 80); gazeX.set(dx / reach); gazeY.set(dy / reach); setTracking(true); setNear(Math.hypot(dx, dy) < 150); }; const release = () => { setTracking(false); setNear(false); gazeX.set(0); gazeY.set(0); }; const poke = () => { stir(); if (dozing) { setDozing(false); callbacks.current.onWake?.(); if (!reduce) { animate(pop, [0.2, 1.18, 1], { duration: 0.45, ease: [0.23, 1, 0.32, 1], times: [0, 0.55, 1] }); animate(jolt, [0, -10, 0], { duration: 0.45, ease: [0.23, 1, 0.32, 1], times: [0, 0.4, 1] }); } return; } setCheer((c) => c + 1); clearTimeout(cheerTimer.current); cheerTimer.current = setTimeout(() => setCheer(0), 1100); if (!reduce) animate(jolt, [0, -6, 0], { duration: 0.36, ease: [0.23, 1, 0.32, 1], times: [0, 0.4, 1] }); }; const happy = cheer > 0 && !dozing; const eyes = dozing ? "closed" : happy ? "happy" : "open"; const perked = (near || happy) && !dozing; const settle = { duration: reduce ? 0 : 1.1, ease: [0.65, 0, 0.35, 1] as const }; return (

{dozing ? `${name} dozed off` : `${name} is keeping you company`}

{dozing ? `You stepped away for a bit. Give ${name} a tap to wake it.` : `Nothing needs you right now. Step away and ${name} will nap.`}

); }