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
193 lines
6.9 KiB
TypeScript
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>
|
|
)
|
|
}
|