Replace the synthesized WAV blips with curated mp3 effects:
- error ("haiya") plays on LLM-down/timeout and save failures, debounced
and on-transition-only, with a reassuring bilingual bubble
- milestone (Chinese fanfare) now fires every 100 words
- all other notifications draw from a rotating pop pool that never repeats
the last sound, so batches of suggestions/tips stay lively
playPop() replaces the per-suggestion-type sound map; thread llmDown through
PetalCompanion -> useCompanion for the error cue. Old synth WAVs removed.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
204 lines
7.1 KiB
TypeScript
204 lines
7.1 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
|
|
llmDown: boolean
|
|
editTick: number
|
|
acceptTick: number
|
|
text: string
|
|
}
|
|
|
|
// 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, llmDown, editTick, acceptTick, text }: Props) {
|
|
const { mood, bubble, dismiss, holdBubble, releaseBubble } = useCompanion({
|
|
wordCount,
|
|
saveStatus,
|
|
llmDown,
|
|
editTick,
|
|
acceptTick,
|
|
text,
|
|
})
|
|
|
|
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}
|
|
onMouseEnter={holdBubble}
|
|
onMouseLeave={releaseBubble}
|
|
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>
|
|
)
|
|
}
|