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
312 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|