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,83 @@
import type { SaveStatus } from '../../hooks/useAutoSave'
import { useCompanion, type Mood } from './useCompanion'
import { LottiePlayer } from './LottiePlayer'
import { MOOD_ANIMATIONS } from './animations'
interface Props {
wordCount: number
saveStatus: SaveStatus
editTick: number
acceptTick: number
}
// Emoji placeholder per mood, used until a Lottie asset is wired for that mood.
const MOOD_EMOJI: Record<Mood, string> = {
idle: '🐱',
happy: '😺',
talking: '😸',
celebrate: '😻',
sleeping: '😴',
}
// PetalCompanion is the cozy corner kitten: it watches the writing session via
// useCompanion and shows a Mandarin-first speech bubble for cheers, tips, and
// 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'
return (
<div className="pointer-events-none fixed bottom-4 right-4 z-40 flex flex-col items-end gap-2">
{bubble && (
<div
role="status"
onClick={dismiss}
className="petal-bubble pointer-events-auto max-w-[260px] cursor-pointer p-3 pr-3.5"
style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-card)',
boxShadow: 'var(--shadow-soft)',
// CJK-first font stack (north star Note #17) — she reads Mandarin.
fontFamily: 'var(--font-ui)',
}}
title="Click to dismiss"
>
<p className="text-sm font-bold leading-snug" style={{ color: 'var(--color-plum)' }}>
{bubble.zh}
</p>
<p className="mt-0.5 text-xs leading-snug" style={{ color: 'var(--color-muted)' }}>
{bubble.en}
</p>
</div>
)}
<div
className={`petal-companion pointer-events-auto select-none ${napping ? 'petal-companion-sleep' : ''}`}
style={{
width: 64,
height: 64,
borderRadius: 'var(--radius-pill)',
background: 'var(--color-surface-alt)',
border: '1px solid var(--color-border)',
boxShadow: 'var(--shadow-soft)',
display: 'grid',
placeItems: 'center',
position: 'relative',
}}
aria-label="Petal companion"
>
<LottiePlayer
animationData={MOOD_ANIMATIONS[mood]}
className="h-14 w-14"
fallback={<span style={{ fontSize: 32, lineHeight: 1 }}>{MOOD_EMOJI[mood]}</span>}
/>
{napping && (
<span className="petal-zzz absolute" aria-hidden style={{ color: 'var(--color-muted)' }}>
z
</span>
)}
</div>
</div>
)
}