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' import { useCardOverlap } from './useCardOverlap' import { onPrefsScopeChange, readPref, writePref } from '../../lib/prefs' import { usePack } from '../../i18n' interface Props { wordCount: number saveStatus: SaveStatus llmDown: boolean editTick: number acceptTick: number text: string // The open document is still empty — the one state the daily writing // invitation is offered in. blankPage: boolean // Called when she takes the invitation up, with the English prompt. onAcceptInvitation: (prompt: string) => void } // Emoji placeholder per mood, used for any mood a companion has no Lottie for. const MOOD_EMOJI: Record = { 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, blankPage, onAcceptInvitation, }: Props) { const t = usePack() const { mood, bubble, dismiss, holdBubble, releaseBubble, acceptInvite, declineInvite, setInviteHandler, } = useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptTick, text, blankPage, }) useEffect(() => setInviteHandler(onAcceptInvitation), [setInviteHandler, onAcceptInvitation]) const [companionId, setCompanionId] = useState( () => readPref(STORAGE_KEY) || DEFAULT_COMPANION, ) // The mascot belongs to the writer, not the browser. That first read happens // before /api/me answers, so pick the choice up again once the account is // known — unless she's already swapped companions in the meantime. const touched = useRef(false) useEffect( () => onPrefsScopeChange(() => { if (touched.current) return setCompanionId(readPref(STORAGE_KEY) || DEFAULT_COMPANION) }), [], ) const companion = COMPANIONS.find((c) => c.id === companionId) ?? COMPANIONS[0] const [pickerOpen, setPickerOpen] = useState(false) const rootRef = useRef(null) const badgeRef = useRef(null) // Stand-in for the badge's corner, used only for measuring — see // useCardOverlap. It sits exactly where the badge sits but never bobs, shrinks // or hovers, so what the mascot yields to can't depend on whether it is // currently yielding. const probeRef = useRef(null) // When suggestion cards stack down into the corner, the kitten fades to // translucent and shrinks a step so the card stays readable and clickable. // It wakes back up whenever it has something to say (bubble) or is being // interacted with (picker open) — except under an open History or Garden // panel, where even a cheer would cover the controls she just reached for. const crowded = useCardOverlap(probeRef) const faded = crowded.modal || (crowded.cards && !pickerOpen && !bubble) // 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) touched.current = true writePref(STORAGE_KEY, id) 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 (
{pickerOpen && (

{t.companion.choose}

{COMPANIONS.map((c) => { const active = c.id === companion.id return ( ) })}
)} {/* The bubble is its own layer, so fading the badge doesn't hide it — hold it back explicitly while a panel is open. useCompanion keeps the bubble in state, so it reappears when she closes the panel. */} {bubble && !pickerOpen && !crowded.modal && (

{bubble.native}

{bubble.en}

{/* The daily invitation's two answers. "Not today" is a real button sitting level with the other one, not a small grey escape — a no that has to be hunted for isn't much of a no. */} {bubble.invite && (
)}
)}
) }