The overlap hook only watched .petal-rail-card, so the History and Garden
drawers still sat under the mascot — with a real control ("写作证明 ·
Writing passport") buried under the halo on the live build.
Match [role="dialog"][aria-modal="true"] as well. Both drawers already
render it, so this covers them and any future drawer without a selector
list to keep in sync.
Two things the follow-up note didn't anticipate:
- The hook now reports { cards, modal } separately. A card overlap still
lets the kitten wake for a bubble; a modal overlap yields
unconditionally — a cheer isn't worth covering the panel she just
opened on purpose.
- The speech bubble is its own layer, so fading the badge didn't hide it.
Hold it back while a panel is open; useCompanion keeps it in state, so
it reappears when she closes the panel.
Also guard the poll's setState on value equality, so the 500 ms tick
stops re-rendering the companion for an unchanged answer.
UX_REVIEW item 1 (redo does not re-apply an accepted suggestion) is
recorded as NOT REPRODUCIBLE. Read the prosemirror-history state directly
and hooked view.dispatch: redo works pressed immediately, after an 18 s
pause that lets a full re-check land, and with the editor never focused.
The doc's hypothesis is false — every re-check transaction is
decoration-only, which prosemirror-history ignores, and canRedo stayed
true throughout. Two real findings from that dig are written into the doc
instead: keyboard undo dies when focus isn't in the editor, and an undone
suggestion stays accepted server-side so its card doesn't reliably return.
Item 5's premise is also partly wrong and now re-scoped: the Chinese
sentence does produce a card with an English rendering, just labeled
Clarity rather than a first-class Translate type.
Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
297 lines
10 KiB
TypeScript
297 lines
10 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)
|
|
|
|
// 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(badgeRef)
|
|
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="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>
|
|
)}
|
|
|
|
<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>
|
|
)
|
|
}
|