Add companion kitten — reactive corner mascot

A cozy bottom-right mascot that reacts to the writing session: cheers on
accepted suggestions and word-count milestones, drops Mandarin-first writing
tips, suggests screen breaks after long stretches, and naps when idle.

- useCompanion: library-agnostic behavior engine (priority + cooldown paced
  so it never nags); tips.ts holds all copy, bilingual zh-first.
- LottiePlayer wraps lottie-web's light build (offline, no eval/CDN) so it
  bundles into the Go binary. Ships with an emoji-kitten placeholder per mood;
  dropping a Lottie cat JSON into animations/index.ts is the only swap needed.
- Speech bubble uses the CJK-first font stack (spec Note #17).

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-25 21:43:58 -07:00
parent 534f7262ab
commit 8a32dde587
10 changed files with 465 additions and 8 deletions

View File

@@ -0,0 +1,41 @@
import { useEffect, useRef } from 'react'
// Light build: drops the eval-based expression engine (smaller bundle, no eval
// warning). Plenty for a looping mascot; swap to 'lottie-web' only if an asset
// genuinely needs After Effects expressions.
import lottie, { type AnimationItem } from 'lottie-web/build/player/lottie_light'
interface Props {
// Parsed Lottie JSON (imported, so it bundles offline). When undefined the
// `fallback` is rendered instead — letting the companion ship before any
// animation asset exists.
animationData?: object
loop?: boolean
className?: string
fallback: React.ReactNode
}
// A thin wrapper over lottie-web (framework-agnostic, no React peer deps, fully
// offline). Reloads the animation whenever the data changes — moods swap by
// passing a different `animationData`.
export function LottiePlayer({ animationData, loop = true, className, fallback }: Props) {
const ref = useRef<HTMLDivElement>(null)
const anim = useRef<AnimationItem | null>(null)
useEffect(() => {
if (!ref.current || !animationData) return
anim.current = lottie.loadAnimation({
container: ref.current,
renderer: 'svg',
loop,
autoplay: true,
animationData,
})
return () => {
anim.current?.destroy()
anim.current = null
}
}, [animationData, loop])
if (!animationData) return <>{fallback}</>
return <div ref={ref} className={className} aria-hidden />
}