Files
petal/web/src/components/Companion/PetalCompanion.tsx
prosolis 95123e8c49 Grow the companion, fix idle fallback, add update banner
Companion: bump the corner mascot ~12% (128→144) and make animation
resolution robust — fall back to the idle clip when a mood has no clip of
its own, and pin the always-asleep cat to one pose so its Lottie never
reloads. This stops a bare emoji from replacing the cute companion on idle.

Update notifications: serve a build id at /api/version (a hash of the
embedded dist/index.html, which changes on every frontend deploy). The
client records its load-time id, polls every 90s (and on tab focus), and
floats a Mandarin-first "new version available" banner offering a refresh
when the id changes.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-25 22:45:23 -07:00

193 lines
6.9 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import type { SaveStatus } from '../../hooks/useAutoSave'
import { useCompanion, type Mood } from './useCompanion'
import { LottiePlayer } from './LottiePlayer'
import { COMPANIONS, DEFAULT_COMPANION } from './companions'
interface Props {
wordCount: number
saveStatus: SaveStatus
editTick: number
acceptTick: number
}
// Emoji placeholder per mood, used for any mood a companion has no Lottie for.
const MOOD_EMOJI: Record<Mood, string> = {
idle: '🐾',
happy: '😺',
talking: '😸',
celebrate: '😻',
sleeping: '🐾',
}
const STORAGE_KEY = 'petal.companion'
// PetalCompanion is the cozy corner mascot: it watches the writing session via
// useCompanion and shows a Mandarin-first speech bubble for cheers, tips, and
// break reminders. Clicking the mascot opens a picker to switch companions
// (the choice persists in localStorage).
export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }: Props) {
const { mood, bubble, dismiss } = useCompanion({ wordCount, saveStatus, editTick, acceptTick })
const [companionId, setCompanionId] = useState<string>(() => {
try {
return localStorage.getItem(STORAGE_KEY) || DEFAULT_COMPANION
} catch {
return DEFAULT_COMPANION
}
})
const companion = COMPANIONS.find((c) => c.id === companionId) ?? COMPANIONS[0]
const [pickerOpen, setPickerOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
// Awake companions (no sleeping clip) don't visibly nap — when the engine
// dozes them, keep their normal idle pose instead of a sleepy face. Only a
// mascot with a real sleep animation (the always-asleep cat) shows the zzz.
const hasSleepClip = Boolean(companion.animations.sleeping)
// An always-asleep mascot (the cat) stays pinned to one pose so its Lottie
// never reloads as the engine flips moods underneath it.
const renderMood: Mood = companion.alwaysAsleep
? 'idle'
: mood === 'sleeping' && !hasSleepClip
? 'idle'
: mood
const napping = companion.alwaysAsleep || (mood === 'sleeping' && hasSleepClip)
// Never swap the cute companion for a bare emoji on an idle nap: fall back to
// the idle animation when a mood has no clip of its own, and only reach for
// the emoji if the companion ships no animations at all.
const animationData = companion.animations[renderMood] ?? companion.animations.idle
function choose(id: string) {
setCompanionId(id)
try {
localStorage.setItem(STORAGE_KEY, id)
} catch {
/* private mode / storage disabled — selection just won't persist */
}
setPickerOpen(false)
}
// Click outside the corner closes the picker.
useEffect(() => {
if (!pickerOpen) return
const onDown = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setPickerOpen(false)
}
document.addEventListener('mousedown', onDown)
return () => document.removeEventListener('mousedown', onDown)
}, [pickerOpen])
return (
<div
ref={rootRef}
className="pointer-events-none fixed bottom-4 right-4 z-40 flex flex-col items-end gap-2"
>
{pickerOpen && (
<div
className="petal-bubble pointer-events-auto flex flex-col gap-0.5 p-1.5"
style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-card)',
boxShadow: 'var(--shadow-soft)',
fontFamily: 'var(--font-ui)',
minWidth: 180,
}}
>
<p
className="px-2 pb-1 pt-0.5 text-[0.7rem] font-bold"
style={{ color: 'var(--color-muted)' }}
>
· Choose a companion
</p>
{COMPANIONS.map((c) => {
const active = c.id === companion.id
return (
<button
key={c.id}
type="button"
onClick={() => choose(c.id)}
className="flex items-center gap-2.5 rounded-xl px-2 py-1.5 text-left text-sm transition-colors"
style={{
background: active ? 'var(--color-surface-alt)' : 'transparent',
color: 'var(--color-plum)',
}}
onMouseEnter={(e) => {
if (!active) e.currentTarget.style.background = 'var(--color-surface-alt)'
}}
onMouseLeave={(e) => {
if (!active) e.currentTarget.style.background = 'transparent'
}}
>
<span style={{ fontSize: 20, lineHeight: 1 }}>{c.emoji}</span>
<span className="flex-1">
<span className="font-bold">{c.zh}</span>{' '}
<span style={{ color: 'var(--color-muted)' }}>{c.name}</span>
</span>
{active && <span style={{ color: 'var(--color-accent)' }}></span>}
</button>
)
})}
</div>
)}
{bubble && !pickerOpen && (
<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>
)}
<button
type="button"
onClick={() => setPickerOpen((o) => !o)}
title="Choose a companion"
aria-label="Choose a companion"
className={`petal-companion pointer-events-auto select-none ${napping ? 'petal-companion-sleep' : ''}`}
style={{
width: 144,
height: 144,
padding: 0,
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',
}}
>
<LottiePlayer
key={companion.id}
animationData={animationData}
className="h-32 w-32"
fallback={<span style={{ fontSize: 72, lineHeight: 1 }}>{MOOD_EMOJI[renderMood]}</span>}
/>
{napping && (
<span className="petal-zzz absolute" aria-hidden style={{ color: 'var(--color-muted)' }}>
z
</span>
)}
</button>
</div>
)
}