Companion: wire real Lottie assets — the always-sleeping cat

- LottiePlayer now auto-crops each asset to its content bbox (union getBBox
  across 6 frames -> square viewBox), so stock files with empty artboard
  padding fill the badge instead of floating tiny in the middle.
- Wire sleeping-cat.json to every mood: she's always asleep in the corner yet
  still mumbles tips and cheers through the bubble. napping hardcoded true so
  she keeps the calm sway + drifting zzz even while "talking".
- happy-dog.json parked in the folder as an awake-mascot alternate.
- Enable resolveJsonModule for the JSON imports.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-25 22:03:02 -07:00
parent 8a32dde587
commit 97f99b27e4
7 changed files with 67 additions and 24 deletions

View File

@@ -14,6 +14,37 @@ interface Props {
fallback: React.ReactNode
}
// Tighten the SVG viewBox to the animation's actual drawn content so an asset
// with a lot of empty artboard padding (common with stock Lottie files) fills
// the badge instead of floating tiny in the middle. We union the content bbox
// across a few frames (the mascot moves) and make it square so it isn't
// distorted by the square container.
function fitToContent(anim: AnimationItem, svg: SVGSVGElement) {
try {
const frames = anim.getDuration(true)
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity
const SAMPLES = 6
for (let i = 0; i < SAMPLES; i++) {
anim.goToAndStop(Math.floor(((frames - 1) * i) / (SAMPLES - 1)), true)
const b = svg.getBBox()
if (b.width === 0 || b.height === 0) continue
x1 = Math.min(x1, b.x)
y1 = Math.min(y1, b.y)
x2 = Math.max(x2, b.x + b.width)
y2 = Math.max(y2, b.y + b.height)
}
if (!isFinite(x1)) return
const cx = (x1 + x2) / 2, cy = (y1 + y2) / 2
const side = Math.max(x2 - x1, y2 - y1) * 1.14 // ~7% breathing room each side
svg.setAttribute('viewBox', `${cx - side / 2} ${cy - side / 2} ${side} ${side}`)
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet')
} catch {
/* getBBox unsupported / not laid out — leave the original viewBox */
} finally {
anim.goToAndPlay(0, true)
}
}
// 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`.
@@ -22,16 +53,22 @@ export function LottiePlayer({ animationData, loop = true, className, fallback }
const anim = useRef<AnimationItem | null>(null)
useEffect(() => {
if (!ref.current || !animationData) return
anim.current = lottie.loadAnimation({
container: ref.current,
const container = ref.current
if (!container || !animationData) return
const item = lottie.loadAnimation({
container,
renderer: 'svg',
loop,
autoplay: true,
animationData,
})
anim.current = item
item.addEventListener('DOMLoaded', () => {
const svg = container.querySelector('svg')
if (svg) fitToContent(item, svg as SVGSVGElement)
})
return () => {
anim.current?.destroy()
item.destroy()
anim.current = null
}
}, [animationData, loop])

View File

@@ -24,7 +24,11 @@ const MOOD_EMOJI: Record<Mood, string> = {
// break reminders. Animation is Lottie when assets exist, emoji otherwise.
export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }: Props) {
const { mood, bubble, dismiss } = useCompanion({ wordCount, saveStatus, editTick, acceptTick })
const napping = mood === 'sleeping'
// This mascot is always asleep (every mood maps to the sleeping-cat loop, see
// animations/index.ts) — so she always gets the calm sway + drifting zzz, even
// while she mumbles tips in her sleep. Flip to `mood === 'sleeping'` if you
// ever swap in an awake mascot.
const napping = true
return (
<div className="pointer-events-none fixed bottom-4 right-4 z-40 flex flex-col items-end gap-2">

File diff suppressed because one or more lines are too long

View File

@@ -1,21 +1,20 @@
import type { Mood } from '../useCompanion'
import sleepingCat from './sleeping-cat.json'
// A Happy Dog clip is also in this folder (happy-dog.json) if you ever want an
// awake, bouncy mascot — import it and map it to the awake/active moods below.
// Lottie animation assets, keyed by mood. Until you add files the companion
// falls back to an emoji kitten that still bobs, naps, and reacts — so every
// behavior works today; this map is the ONLY thing to change when assets land.
// Lottie animation assets, keyed by mood. Assets are pure vector (no
// expressions/images) so they render on the lottie-web light build, fully
// offline; LottiePlayer auto-crops each to its content bbox.
//
// To wire a real kitten:
// 1. Grab a Lottie cat JSON (e.g. from lottiefiles.com) and save it here,
// e.g. ./kitten-idle.json. Prefer plain Lottie JSON (not .lottie) so it
// bundles offline with no runtime CDN fetch.
// 2. Import and map it:
// import idle from './kitten-idle.json'
// import happy from './kitten-happy.json'
// export const MOOD_ANIMATIONS: Partial<Record<Mood, object>> = {
// idle, happy, talking: happy, celebrate: happy, sleeping: idle,
// }
// 3. (For JSON imports, TS needs resolveJsonModule — already on in tsconfig.)
//
// A single animation for every mood is totally fine to start; fill in others as
// you find them. Any mood left unset uses the emoji fallback for that mood only.
export const MOOD_ANIMATIONS: Partial<Record<Mood, object>> = {}
// Per the resident feline: the cat is ALWAYS asleep. Every mood maps to the
// same sleeping-cat loop, so she snoozes peacefully in the corner — and yet
// still somehow talks in her sleep, dispensing tips and encouragement through
// the speech bubble. (Yes, this is intentional. It's funnier this way.)
export const MOOD_ANIMATIONS: Partial<Record<Mood, object>> = {
idle: sleepingCat,
happy: sleepingCat,
talking: sleepingCat,
celebrate: sleepingCat,
sleeping: sleepingCat,
}

File diff suppressed because one or more lines are too long