"use client"; import { type AnimationPlaybackControls, animate, motion, useInView, useMotionValue, useReducedMotion, useSpring, useTransform, } from "motion/react"; import { useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export type KeyboardCatHue = "orange" | "amber" | "rose" | "violet" | "sky" | "teal" | "emerald" | "indigo"; const hues: Record = { orange: "[--hue-hi:var(--color-orange-300)] [--hue:var(--color-orange-400)] [--hue-deep:var(--color-orange-600)] [--soft:var(--color-orange-100)] [--ink:var(--color-orange-950)]", amber: "[--hue-hi:var(--color-amber-300)] [--hue:var(--color-amber-400)] [--hue-deep:var(--color-amber-600)] [--soft:var(--color-amber-100)] [--ink:var(--color-amber-950)]", rose: "[--hue-hi:var(--color-rose-300)] [--hue:var(--color-rose-400)] [--hue-deep:var(--color-rose-600)] [--soft:var(--color-rose-100)] [--ink:var(--color-rose-950)]", violet: "[--hue-hi:var(--color-violet-300)] [--hue:var(--color-violet-400)] [--hue-deep:var(--color-violet-600)] [--soft:var(--color-violet-100)] [--ink:var(--color-violet-950)]", sky: "[--hue-hi:var(--color-sky-300)] [--hue:var(--color-sky-400)] [--hue-deep:var(--color-sky-600)] [--soft:var(--color-sky-100)] [--ink:var(--color-sky-950)]", teal: "[--hue-hi:var(--color-teal-300)] [--hue:var(--color-teal-400)] [--hue-deep:var(--color-teal-600)] [--soft:var(--color-teal-100)] [--ink:var(--color-teal-950)]", emerald: "[--hue-hi:var(--color-emerald-300)] [--hue:var(--color-emerald-400)] [--hue-deep:var(--color-emerald-600)] [--soft:var(--color-emerald-100)] [--ink:var(--color-emerald-950)]", indigo: "[--hue-hi:var(--color-indigo-300)] [--hue:var(--color-indigo-400)] [--hue-deep:var(--color-indigo-600)] [--soft:var(--color-indigo-100)] [--ink:var(--color-indigo-950)]", }; export interface KeyboardCatProps { name?: string; hue?: KeyboardCatHue; progress?: number; label?: string; doneLabel?: string; doneDescription?: string; onDone?: () => void; className?: string; } type Phase = "working" | "waking" | "gone" | "returning"; type Key = { x: number; y: number; w: number; h: number }; const VB = { x: 40, y: 14, w: 320, h: 214 }; const GAP = 3; function deckEdges(y: number) { const t = (y - 150) / 56; return { left: 110 - 32 * t + 8, right: 290 + 32 * t - 8 }; } function row(y: number, h: number, n: number): Key[] { const { left, right } = deckEdges(y + h / 2); const w = (right - left - GAP * (n - 1)) / n; return Array.from({ length: n }, (_, i) => ({ x: left + i * (w + GAP), y, w, h })); } function spaceRow(y: number, h: number): Key[] { const { left, right } = deckEdges(y + h / 2); const bar = (right - left) * 0.4; const side = (right - left - bar - GAP * 2) / 2; const w = (side - GAP * 2) / 3; const keys: Key[] = []; for (let i = 0; i < 3; i++) keys.push({ x: left + i * (w + GAP), y, w, h }); keys.push({ x: left + side + GAP, y, w: bar, h }); for (let i = 0; i < 3; i++) keys.push({ x: left + side + bar + GAP * 2 + i * (w + GAP), y, w, h }); return keys; } const KEYS: Key[] = [...row(155, 9, 12), ...row(167, 9, 12), ...row(179, 9, 11), ...spaceRow(191, 8)]; function nearest(x: number, y: number) { let best = 0; let dist = Number.POSITIVE_INFINITY; KEYS.forEach((k, i) => { const d = Math.hypot(k.x + k.w / 2 - x, k.y + k.h / 2 - y); if (d < dist) { dist = d; best = i; } }); return best; } const PAWS = [ { x: 166, y: 194 }, { x: 192, y: 195 }, ]; const PAW_KEYS = PAWS.map((p) => nearest(p.x, p.y)); const OPEN_KEYS = KEYS.map((k, i) => ({ k, i })) .filter(({ k }) => k.x + k.w < 146 || k.x > 306) .map(({ i }) => i); const LINES = [ { w: 72, kw: 20 }, { w: 104, kw: 26 }, { w: 58, kw: 16 }, { w: 92, kw: 22 }, { w: 44, kw: 14 }, ]; function useCalmMotion() { const prefers = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted && !!prefers; } export function KeyboardCat({ name = "Mochi", hue = "orange", progress, label = "Installing dependencies", doneLabel = "All installed", doneDescription = "1,284 packages, no warnings.", onDone, className, }: KeyboardCatProps) { const reduce = useCalmMotion(); const root = useRef(null); const inView = useInView(root); const live = inView && !reduce; const simulated = progress === undefined; const [simPct, setSimPct] = useState(0); const pct = Math.round(Math.min(100, Math.max(0, simulated ? simPct : progress))); const done = pct >= 100; const [phase, setPhase] = useState("working"); const [tick, setTick] = useState({ beat: 0, lit: [] as number[] }); const [peek, setPeek] = useState<0 | 1 | null>(null); const [twitch, setTwitch] = useState({ ear: 0, n: 0 }); const bar = useMotionValue(0); const barSmooth = useSpring(bar, { stiffness: 140, damping: 24 }); const barWidth = useTransform(barSmooth, (v) => Math.max(0, v) * 132); const catX = useMotionValue(0); const catY = useMotionValue(0); const catRotate = useMotionValue(0); const catOpacity = useMotionValue(1); const stretchX = useMotionValue(1); const stretchY = useMotionValue(1); const onDoneRef = useRef(onDone); useEffect(() => { onDoneRef.current = onDone; }); const lastPct = useRef(pct); useEffect(() => { if (pct < lastPct.current) barSmooth.jump(pct / 100); bar.set(pct / 100); lastPct.current = pct; }, [pct, bar, barSmooth]); useEffect(() => { if (!simulated || done || phase !== "working" || !inView) return; const timer = setInterval(() => { setSimPct((p) => { if (Math.random() < 0.12) return p; return Math.min(100, p + 0.6 + Math.random() * 2.6); }); }, 140); return () => clearInterval(timer); }, [simulated, done, phase, inView]); useEffect(() => { if (phase !== "working" || !inView || done) { setTick((t) => (t.lit.length ? { beat: t.beat, lit: [] } : t)); return; } const timer = setInterval(() => { setTick((t) => { const beat = t.beat + 1; const lit = [PAW_KEYS[beat % 2]]; if (Math.random() < 0.55) lit.push(OPEN_KEYS[Math.floor(Math.random() * OPEN_KEYS.length)]); return { beat, lit }; }); }, 320); return () => clearInterval(timer); }, [phase, inView, done]); useEffect(() => { if (!live || phase !== "working") return; let timer: ReturnType; let peekTimer: ReturnType; const schedule = () => { timer = setTimeout( () => { if (Math.random() < 0.3) { setPeek(Math.random() < 0.5 ? 0 : 1); peekTimer = setTimeout(() => setPeek(null), 1100); } else { setTwitch((t) => ({ ear: Math.random() < 0.5 ? 0 : 1, n: t.n + 1 })); } schedule(); }, 1800 + Math.random() * 2600, ); }; schedule(); return () => { clearTimeout(timer); clearTimeout(peekTimer); setPeek(null); }; }, [live, phase]); const phaseRef = useRef("working"); const doneRef = useRef(done); const runId = useRef(0); const running = useRef([]); const timers = useRef(new Set>()); useEffect(() => { const pending = timers.current; return () => { runId.current++; for (const t of pending) clearTimeout(t); for (const c of running.current) c.stop(); }; }, []); const stage = (next: Phase) => { phaseRef.current = next; setPhase(next); }; const wait = (ms: number) => new Promise((resolve) => { const t = setTimeout(() => { timers.current.delete(t); resolve(); }, ms); timers.current.add(t); }); const run = (c: AnimationPlaybackControls) => { running.current.push(c); return c; }; const leave = async () => { const id = ++runId.current; const alive = () => runId.current === id; running.current = []; stage("waking"); await wait(reduce ? 200 : 420); if (!alive()) return; if (reduce) { await run(animate(catOpacity, 0, { duration: 0.4 })); } else { run(animate(catRotate, -5, { duration: 0.45, ease: [0.65, 0, 0.35, 1] })); run(animate(stretchX, 1.14, { duration: 0.45, ease: [0.65, 0, 0.35, 1] })); await run(animate(stretchY, 0.86, { duration: 0.45, ease: [0.65, 0, 0.35, 1] })); if (!alive()) return; await wait(260); if (!alive()) return; run(animate(catRotate, 0, { duration: 0.28, ease: [0.23, 1, 0.32, 1] })); run(animate(stretchX, 1, { duration: 0.28, ease: [0.23, 1, 0.32, 1] })); await run(animate(stretchY, 1, { duration: 0.28, ease: [0.23, 1, 0.32, 1] })); if (!alive()) return; run(animate(catRotate, [0, -10, 4], { duration: 0.6, ease: "easeInOut" })); run(animate(catY, [0, -48, -30, 40], { duration: 0.6, ease: "easeIn", times: [0, 0.4, 0.62, 1] })); await run(animate(catX, [0, 90, 170, 290], { duration: 0.6, ease: "linear" })); } if (!alive()) return; stage("gone"); onDoneRef.current?.(); if (!doneRef.current) comeBack(); }; const comeBack = async () => { const id = ++runId.current; const alive = () => runId.current === id; running.current = []; stage("returning"); if (reduce) { catX.set(0); catY.set(0); catRotate.set(0); await run(animate(catOpacity, 1, { duration: 0.4 })); } else { catOpacity.set(1); run(animate(catRotate, [6, -6, 0], { duration: 0.6, ease: "easeOut" })); run(animate(catY, [40, -46, 0], { duration: 0.6, ease: "easeOut", times: [0, 0.5, 1] })); await run(animate(catX, [290, 120, 0], { duration: 0.6, ease: [0.23, 1, 0.32, 1] })); if (!alive()) return; run(animate(stretchY, [1, 0.9, 1], { duration: 0.32, ease: [0.23, 1, 0.32, 1] })); await run(animate(stretchX, [1, 1.06, 1], { duration: 0.32, ease: [0.23, 1, 0.32, 1] })); if (!alive()) return; await wait(450); } if (!alive()) return; stage("working"); if (doneRef.current) leave(); }; useEffect(() => { doneRef.current = done; if (done && phaseRef.current === "working") leave(); if (!done && phaseRef.current === "gone") comeBack(); }, [done]); useEffect(() => { if (!simulated || phase !== "gone" || !inView) return; const timer = setTimeout(() => setSimPct(0), 2600); return () => clearTimeout(timer); }, [simulated, phase, inView]); const replay = () => { if (phase === "gone" && simulated) { setSimPct(0); return; } if (phase === "working") setTwitch((t) => ({ ear: t.n % 2 === 0 ? 0 : 1, n: t.n + 1 })); }; const awake = phase !== "working"; const kneading = live && phase === "working" && !done; const revealed = (i: number) => Math.min(1, Math.max(0, (pct / 100) * (LINES.length + 0.6) - i)); const caretLine = Math.min(LINES.length - 1, Math.floor((pct / 100) * (LINES.length + 0.6))); const caretX = 134 + LINES[caretLine].w * revealed(caretLine) + 2; const finished = phase === "gone" || (phase === "waking" && done); return (

{finished ? doneLabel : label}

{finished ? ( doneDescription ) : ( <> {pct}% ยท {name} is keeping the keys warm )}

); }