"use client"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { EASE_OUT, SPRING_PANEL } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type VoiceOrbState = "idle" | "listening" | "thinking" | "speaking"; export interface VoiceOrbProps { /** Conversational state — drives breathing rhythm, hue drift, echo rings and the thinking sheen. */ state?: VoiceOrbState; /** Orb diameter in pixels. */ size?: number; className?: string; } // Brand blue (#339CFF — the library's accent, used elsewhere for focus rings) // carried as an rgb triplet so the glow shadow can share it at low alpha. const GLOW_RGB = "51, 156, 255"; const ORB_BACKGROUND = "radial-gradient(circle at 30% 26%, #d9ecff 0%, #339CFF 38%, #1c5aa8 68%, #0a2540 100%)"; const ORB_SHADOW = [ `0 0 64px rgba(${GLOW_RGB}, 0.45)`, `0 0 130px rgba(${GLOW_RGB}, 0.22)`, "inset 0 -20px 42px rgba(4, 12, 26, 0.4)", "inset 0 16px 28px rgba(255, 255, 255, 0.2)", ].join(", "); /** * Per-state breathing rhythm — the ball's own continuous `animate` target. * These durations/eases are bespoke to the breathing/echo feel rather than UI * transitions, so they live as named constants instead of `lib/ease.ts` * tokens (which are tuned for <300ms UI moves and panel entrances): idle is * a slow, low-lit drift; listening/speaking sync the scale with a hue or * brightness pulse; thinking calms the breathing so the rotating sheen below * carries the motion instead. */ const BREATH: Record< VoiceOrbState, { scale: number[]; filter: string | string[]; duration: number } > = { idle: { scale: [1, 1.03, 1], filter: "brightness(0.85)", duration: 5, }, listening: { scale: [1, 1.06, 1], filter: [ "hue-rotate(0deg) brightness(1)", "hue-rotate(15deg) brightness(1.05)", "hue-rotate(0deg) brightness(1)", ], duration: 2.4, }, thinking: { scale: [1, 1.02, 1], filter: ["brightness(0.95)", "brightness(1.05)", "brightness(0.95)"], duration: 3.2, }, speaking: { scale: [1, 1.09, 1.02, 1.12, 1], filter: ["brightness(1)", "brightness(1.15)", "brightness(1)"], duration: 1.6, }, }; /** Expanding echo-ring, cloned twice with a stagger — a decelerating ripple radiating off the ball's edge. */ function EchoRing({ duration, delay }: { duration: number; delay: number }) { return ( ); } /** * Central voice-mode orb — a single continuous ball that never unmounts * across `idle` / `listening` / `thinking` / `speaking`; only the breathing * `animate` target changes, so the motion carries through state switches * instead of resetting. Echo rings (listening/speaking) and the thinking * sheen are the only pieces that mount/unmount — they spring in with * `SPRING_PANEL` and fade out with `EASE_OUT`, quicker out than in, matching * the library's exit convention. Fully static under `useReducedMotion()`: no * breathing, no rings, no rotation — just the plain gradient ball. * * Purely decorative (`aria-hidden`); pair it with your own status text for * the accessible state announcement. */ export function VoiceOrb({ state = "idle", size = 160, className, }: VoiceOrbProps) { const reduce = useReducedMotion() ?? false; const breath = BREATH[state]; const showRings = state === "listening" || state === "speaking"; const showSheen = state === "thinking"; // Speaking answers back faster than listening picks up — the echo halves in length. const echoDuration = state === "speaking" ? 1.2 : 2; return (
{!reduce && showRings ? ( ) : null} {!reduce && showSheen ? ( ) : null}
); }