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:
83
web/src/components/Companion/PetalCompanion.tsx
Normal file
83
web/src/components/Companion/PetalCompanion.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user