- Three new Lottie companions: Wiggle Dog (摇尾狗), Butterfly (蝴蝶), Parrot (鹦鹉). Parrot is mirrored via a new `flip` flag on Companion, threaded through LottiePlayer (scaleX(-1)) since the asset faces left. - Mascot size now scales with the viewport: --petal-companion-size clamp(10rem, 17vw, 20rem); art, emoji fallback, and the sleep "z" all derive from it. - Larger, more legible tip bubble (1.4rem zh / 1.15rem en, wider bubble). - Bubbles linger longer for a bilingual ESL read: baseline 9s→14s, cheer 6s→9s, per-char 45→55ms, cap 20s→32s. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
216 lines
7.4 KiB
TypeScript
216 lines
7.4 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-[420px] cursor-pointer p-4 pr-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="font-bold leading-snug"
|
|
style={{ color: 'var(--color-plum)', fontSize: '1.4rem' }}
|
|
>
|
|
{bubble.zh}
|
|
</p>
|
|
<p
|
|
className="mt-0.5 leading-snug"
|
|
style={{ color: 'var(--color-muted)', fontSize: '1.15rem' }}
|
|
>
|
|
{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={{
|
|
// Size scales with the viewport — see --petal-companion-size in index.css.
|
|
width: 'var(--petal-companion-size)',
|
|
height: 'var(--petal-companion-size)',
|
|
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}
|
|
flip={companion.flip}
|
|
className="petal-companion-art"
|
|
fallback={
|
|
<span style={{ fontSize: 'calc(var(--petal-companion-size) * 0.5)', lineHeight: 1 }}>
|
|
{MOOD_EMOJI[renderMood]}
|
|
</span>
|
|
}
|
|
/>
|
|
{napping && (
|
|
<span className="petal-zzz absolute" aria-hidden style={{ color: 'var(--color-muted)' }}>
|
|
z
|
|
</span>
|
|
)}
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|