Files
petal/web/src/components/Companion/PetalCompanion.tsx
T
prosolis f82f2b589d Measure the corner the kitten sits in, not the kitten
The overlap test read the badge's own box, which bobs, shrinks 10% when it
yields, and grows 4% on hover — so the mascot's answer to "is a card in my way"
depended on what it was currently doing. Against the live build, with three
cards up and no bubble, it flipped between drawn widths of 293 and 264 on its
own: the 0.9 yield scale, cycling.

Measure a probe span instead. It sits exactly where the badge sits, never
animates, and moves the size variable to .petal-corner so both are sized from
the same number. Nothing the mascot does can now change what it yields to.

Cards also settle a pixel from the corner routinely — a rail re-pack, a resize,
a browser bar appearing — so hold a yield until the card has retreated 24px
rather than deciding on the exact edge.

Verified in a real browser over CDP: seven overlap depths against the mascot's
top edge, each settling once and holding, drawn width steady where it used to
swing.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-28 22:21:33 -07:00

312 lines
11 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'
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<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,
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<string>(
() => 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<HTMLDivElement>(null)
const badgeRef = useRef<HTMLButtonElement>(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<HTMLSpanElement>(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 (
<div
ref={rootRef}
className="petal-corner 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)' }}
>
{t.companion.choose}
</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">{t.companion.names[c.id] ?? c.name}</span>{' '}
<span style={{ color: 'var(--color-muted)' }}>{c.name}</span>
</span>
{active && <span style={{ color: 'var(--color-accent)' }}></span>}
</button>
)
})}
</div>
)}
{/* 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 && (
<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.native}
</p>
<p
className="mt-0.5 leading-snug"
style={{ color: 'var(--color-muted)', fontSize: '1.15rem' }}
>
{bubble.en}
</p>
{/* 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 && (
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
acceptInvite(bubble.invite!.prompt)
}}
className="rounded-full px-3.5 py-1.5 text-sm font-bold"
style={{ background: 'var(--color-accent)', color: 'var(--color-plum)' }}
>
{t.companion.inviteAccept}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
declineInvite()
}}
className="rounded-full px-3.5 py-1.5 text-sm font-semibold"
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
>
{t.companion.inviteDecline}
</button>
</div>
)}
</div>
)}
<span
ref={probeRef}
aria-hidden
className="pointer-events-none absolute bottom-0 right-0"
style={{
width: 'var(--petal-companion-size, 9rem)',
height: 'var(--petal-companion-size, 9rem)',
}}
/>
<button
ref={badgeRef}
type="button"
onClick={() => setPickerOpen((o) => !o)}
title="Choose a companion"
aria-label="Choose a companion"
className={`petal-companion select-none ${faded ? 'petal-companion-faded' : 'pointer-events-auto'}${
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>
)
}