Add companion kitten — reactive corner mascot

A cozy bottom-right mascot that reacts to the writing session: cheers on
accepted suggestions and word-count milestones, drops Mandarin-first writing
tips, suggests screen breaks after long stretches, and naps when idle.

- useCompanion: library-agnostic behavior engine (priority + cooldown paced
  so it never nags); tips.ts holds all copy, bilingual zh-first.
- LottiePlayer wraps lottie-web's light build (offline, no eval/CDN) so it
  bundles into the Go binary. Ships with an emoji-kitten placeholder per mood;
  dropping a Lottie cat JSON into animations/index.ts is the only swap needed.
- Speech bubble uses the CJK-first font stack (spec Note #17).

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-25 21:43:58 -07:00
parent 534f7262ab
commit 8a32dde587
10 changed files with 465 additions and 8 deletions

View File

@@ -5,6 +5,7 @@ import { useCheckpoint } from './hooks/useCheckpoint'
import { DocList } from './components/DocList/DocList'
import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
import { StatusBar } from './components/StatusBar/StatusBar'
import { PetalCompanion } from './components/Companion/PetalCompanion'
export default function App() {
const [docs, setDocs] = useState<DocSummary[]>([])
@@ -16,6 +17,9 @@ export default function App() {
// sidebar. Escape or a click outside the editor canvas restores it.
const [focusMode, setFocusMode] = useState(false)
const canvasRef = useRef<HTMLDivElement>(null)
// Monotonic counters the companion watches to react to writing + accepts.
const [editTick, setEditTick] = useState(0)
const [acceptTick, setAcceptTick] = useState(0)
const { status, schedule, saveNow } = useAutoSave(currentDoc?.id ?? null)
const {
@@ -116,6 +120,7 @@ export default function App() {
const handleEditorChange = useCallback(
(change: EditorChange) => {
setWordCount(change.word_count)
setEditTick((n) => n + 1)
if (currentDoc) {
patchSummary(currentDoc.id, { word_count: change.word_count })
schedule(change)
@@ -130,6 +135,7 @@ export default function App() {
const handleAccept = useCallback(
async (s: Suggestion) => {
removeSuggestion(s.id)
setAcceptTick((n) => n + 1)
try {
await api.acceptSuggestion(s.id)
} catch (err) {
@@ -238,6 +244,13 @@ export default function App() {
)}
</main>
</div>
<PetalCompanion
wordCount={wordCount}
saveStatus={status}
editTick={editTick}
acceptTick={acceptTick}
/>
</div>
)
}

View File

@@ -0,0 +1,41 @@
import { useEffect, useRef } from 'react'
// Light build: drops the eval-based expression engine (smaller bundle, no eval
// warning). Plenty for a looping mascot; swap to 'lottie-web' only if an asset
// genuinely needs After Effects expressions.
import lottie, { type AnimationItem } from 'lottie-web/build/player/lottie_light'
interface Props {
// Parsed Lottie JSON (imported, so it bundles offline). When undefined the
// `fallback` is rendered instead — letting the companion ship before any
// animation asset exists.
animationData?: object
loop?: boolean
className?: string
fallback: React.ReactNode
}
// A thin wrapper over lottie-web (framework-agnostic, no React peer deps, fully
// offline). Reloads the animation whenever the data changes — moods swap by
// passing a different `animationData`.
export function LottiePlayer({ animationData, loop = true, className, fallback }: Props) {
const ref = useRef<HTMLDivElement>(null)
const anim = useRef<AnimationItem | null>(null)
useEffect(() => {
if (!ref.current || !animationData) return
anim.current = lottie.loadAnimation({
container: ref.current,
renderer: 'svg',
loop,
autoplay: true,
animationData,
})
return () => {
anim.current?.destroy()
anim.current = null
}
}, [animationData, loop])
if (!animationData) return <>{fallback}</>
return <div ref={ref} className={className} aria-hidden />
}

View File

@@ -0,0 +1,83 @@
import type { SaveStatus } from '../../hooks/useAutoSave'
import { useCompanion, type Mood } from './useCompanion'
import { LottiePlayer } from './LottiePlayer'
import { MOOD_ANIMATIONS } from './animations'
interface Props {
wordCount: number
saveStatus: SaveStatus
editTick: number
acceptTick: number
}
// Emoji placeholder per mood, used until a Lottie asset is wired for that mood.
const MOOD_EMOJI: Record<Mood, string> = {
idle: '🐱',
happy: '😺',
talking: '😸',
celebrate: '😻',
sleeping: '😴',
}
// PetalCompanion is the cozy corner kitten: it watches the writing session via
// useCompanion and shows a Mandarin-first speech bubble for cheers, tips, and
// break reminders. Animation is Lottie when assets exist, emoji otherwise.
export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }: Props) {
const { mood, bubble, dismiss } = useCompanion({ wordCount, saveStatus, editTick, acceptTick })
const napping = mood === 'sleeping'
return (
<div className="pointer-events-none fixed bottom-4 right-4 z-40 flex flex-col items-end gap-2">
{bubble && (
<div
role="status"
onClick={dismiss}
className="petal-bubble pointer-events-auto max-w-[260px] cursor-pointer p-3 pr-3.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="text-sm font-bold leading-snug" style={{ color: 'var(--color-plum)' }}>
{bubble.zh}
</p>
<p className="mt-0.5 text-xs leading-snug" style={{ color: 'var(--color-muted)' }}>
{bubble.en}
</p>
</div>
)}
<div
className={`petal-companion pointer-events-auto select-none ${napping ? 'petal-companion-sleep' : ''}`}
style={{
width: 64,
height: 64,
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',
}}
aria-label="Petal companion"
>
<LottiePlayer
animationData={MOOD_ANIMATIONS[mood]}
className="h-14 w-14"
fallback={<span style={{ fontSize: 32, lineHeight: 1 }}>{MOOD_EMOJI[mood]}</span>}
/>
{napping && (
<span className="petal-zzz absolute" aria-hidden style={{ color: 'var(--color-muted)' }}>
z
</span>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,21 @@
import type { Mood } from '../useCompanion'
// Lottie animation assets, keyed by mood. Until you add files the companion
// falls back to an emoji kitten that still bobs, naps, and reacts — so every
// behavior works today; this map is the ONLY thing to change when assets land.
//
// To wire a real kitten:
// 1. Grab a Lottie cat JSON (e.g. from lottiefiles.com) and save it here,
// e.g. ./kitten-idle.json. Prefer plain Lottie JSON (not .lottie) so it
// bundles offline with no runtime CDN fetch.
// 2. Import and map it:
// import idle from './kitten-idle.json'
// import happy from './kitten-happy.json'
// export const MOOD_ANIMATIONS: Partial<Record<Mood, object>> = {
// idle, happy, talking: happy, celebrate: happy, sleeping: idle,
// }
// 3. (For JSON imports, TS needs resolveJsonModule — already on in tsconfig.)
//
// A single animation for every mood is totally fine to start; fill in others as
// you find them. Any mood left unset uses the emoji fallback for that mood only.
export const MOOD_ANIMATIONS: Partial<Record<Mood, object>> = {}

View File

@@ -0,0 +1,63 @@
// Bilingual companion copy — Mandarin first (she writes/reads in Mandarin), with
// a warm English subtitle. Kept gentle and encouraging, never scolding. The
// bubble renders zh prominently and en underneath. (Spec north star: CJK is
// first-class; Ask Petal & companion answer in Mandarin.)
export interface Line {
zh: string
en: string
}
// Played when a suggestion is accepted or a milestone hits — pure warmth.
export const ENCOURAGEMENTS: Line[] = [
{ zh: '好棒!这一句更顺了 🌸', en: 'Lovely — that reads so much smoother now.' },
{ zh: '你写得越来越好了 ✨', en: "You're getting better and better." },
{ zh: '我很喜欢这个改法 💕', en: 'I really like that change.' },
{ zh: '继续保持,加油!', en: 'Keep going — youve got this!' },
{ zh: '嗯嗯,这样清楚多了 👍', en: 'Mm, thats much clearer.' },
]
// Gentle, periodic writing/ESL tips. Surfaced only when nothing else is showing.
export const TIPS: Line[] = [
{ zh: '小贴士:英文句子短一点,会更清楚哦。', en: 'Tip: shorter English sentences often read clearer.' },
{ zh: '别忘了冠词 “the” 和 “a” 哦。', en: "Don't forget articles like “the” and “a”." },
{ zh: '过去的事情用过去式go → went。', en: 'For the past, use past tense: go → went.' },
{ zh: '读出声音,能帮你发现奇怪的地方。', en: 'Reading aloud helps you catch awkward spots.' },
{ zh: '一个段落讲一个想法就好。', en: 'One idea per paragraph keeps it tidy.' },
{ zh: '不确定的地方,问问我就好啦 ✨', en: 'Not sure about something? Just ask me. ✨' },
{ zh: '复数别忘了加 stwo apples 🍎', en: 'Plurals take an “s”: two apples 🍎' },
]
// Shown after a long stretch of continuous writing.
export const BREAKS: Line[] = [
{ zh: '写了好一会儿啦,起来走走,让眼睛休息一下 🍵', en: "You've been writing a while — stretch and rest your eyes. 🍵" },
{ zh: '喝口水,休息五分钟好不好?', en: 'Sip some water and take five?' },
{ zh: '看看远方,放松一下眼睛 🌿', en: 'Look into the distance for a moment — give your eyes a break. 🌿' },
]
// First hello when the app opens.
export const GREETING: Line = {
zh: '嗨~我在这儿陪你写作哦 🐱',
en: "Hi! I'm right here keeping you company. 🐱",
}
// Welcome-back nudge after she returns from an idle pause.
export const WELCOME_BACK: Line = {
zh: '欢迎回来 ✨ 我们继续吧!',
en: 'Welcome back ✨ lets keep going!',
}
// Word-count milestones worth a little cheer.
export const MILESTONES = [50, 100, 250, 500, 1000, 2000]
export function milestoneLine(n: number): Line {
return {
zh: `哇!已经 ${n} 个词了,太厉害了 🎉`,
en: `Wow — ${n} words already! Amazing. 🎉`,
}
}
// Deterministic-enough random pick (Math.random is fine in the browser runtime).
export function pick<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)]
}

View File

@@ -0,0 +1,182 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { SaveStatus } from '../../hooks/useAutoSave'
import {
BREAKS,
ENCOURAGEMENTS,
GREETING,
MILESTONES,
TIPS,
WELCOME_BACK,
milestoneLine,
pick,
type Line,
} from './tips'
// The kitten's expression. Maps to a Lottie animation when assets are present,
// otherwise to an emoji placeholder (see PetalCompanion).
export type Mood = 'idle' | 'happy' | 'talking' | 'sleeping' | 'celebrate'
export type BubbleTone = 'cheer' | 'tip' | 'break'
export interface Bubble extends Line {
tone: BubbleTone
}
interface Signals {
wordCount: number
saveStatus: SaveStatus
// Monotonic counters bumped by the app on each editor change / accepted
// suggestion — lets the companion react without a full event bus.
editTick: number
acceptTick: number
}
// Timing knobs (ms). Tuned to feel present but never naggy.
const IDLE_MS = 75_000 // no edits → kitten naps
const BREAK_MS = 25 * 60_000 // continuous writing → suggest a break
const TIP_MIN_GAP = 4 * 60_000 // at most one spontaneous tip per this window
const PROACTIVE_GAP = 40_000 // floor between any two unsolicited bubbles
const BUBBLE_MS = 6_500 // how long a bubble lingers
const CHEER_MS = 4_500 // shorter for quick cheers
const now = () => Date.now()
// useCompanion is the behavior engine: it watches writing signals and decides
// when the kitten speaks, what mood it shows, and how to pace itself so the
// companion feels alive without interrupting. UI-agnostic — returns state only.
export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Signals) {
const [mood, setMood] = useState<Mood>('idle')
const [bubble, setBubble] = useState<Bubble | null>(null)
const lastActivity = useRef(now())
const sessionStart = useRef(now())
const lastProactive = useRef(0)
const lastTip = useRef(0)
const lastBreak = useRef(0)
const nextMilestone = useRef(0) // index into MILESTONES
const sleeping = useRef(false)
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
const moodTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
// Show a bubble + talking mood, then settle back. `proactive` messages respect
// the spacing floor; user-triggered ones (cheers) always go through.
const say = useCallback((b: Bubble, opts?: { proactive?: boolean; celebrate?: boolean }) => {
const t = now()
if (opts?.proactive) {
if (bubble) return
if (t - lastProactive.current < PROACTIVE_GAP) return
lastProactive.current = t
}
sleeping.current = false
setBubble(b)
setMood(opts?.celebrate ? 'celebrate' : 'talking')
clearTimeout(bubbleTimer.current)
clearTimeout(moodTimer.current)
const dur = b.tone === 'cheer' ? CHEER_MS : BUBBLE_MS
bubbleTimer.current = setTimeout(() => setBubble(null), dur)
moodTimer.current = setTimeout(() => setMood('idle'), dur)
}, [bubble])
const dismiss = useCallback(() => {
clearTimeout(bubbleTimer.current)
clearTimeout(moodTimer.current)
setBubble(null)
setMood('idle')
}, [])
// Opening hello (once), after a short beat so it doesn't race the first paint.
useEffect(() => {
const id = setTimeout(() => say({ ...GREETING, tone: 'tip' }), 1200)
return () => clearTimeout(id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Edits: record activity and wake from a nap with a warm welcome-back.
const firstEdit = useRef(true)
useEffect(() => {
if (firstEdit.current) {
firstEdit.current = false
return
}
lastActivity.current = now()
if (sleeping.current) {
sleeping.current = false
sessionStart.current = now() // a fresh stretch starts on return
say({ ...WELCOME_BACK, tone: 'cheer' })
} else if (mood === 'sleeping') {
setMood('idle')
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editTick])
// Accepts: always cheer (it's a direct response to her action).
const firstAccept = useRef(true)
useEffect(() => {
if (firstAccept.current) {
firstAccept.current = false
return
}
say({ ...pick(ENCOURAGEMENTS), tone: 'cheer' }, { celebrate: true })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [acceptTick])
// Word-count milestones: cheer the first time each threshold is crossed.
useEffect(() => {
while (
nextMilestone.current < MILESTONES.length &&
wordCount >= MILESTONES[nextMilestone.current]
) {
const n = MILESTONES[nextMilestone.current]
nextMilestone.current += 1
// Skip silently if we're just loading a long doc (no edits yet).
if (!firstEdit.current) say({ ...milestoneLine(n), tone: 'cheer' }, { celebrate: true })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wordCount])
// Occasional gentle cheer on a successful save (kept rare so it isn't noise).
useEffect(() => {
if (saveStatus === 'saved' && Math.random() < 0.18) {
say({ ...pick(ENCOURAGEMENTS), tone: 'cheer' }, { proactive: true })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [saveStatus])
// Heartbeat: nap when idle, suggest breaks, and drop the odd writing tip.
useEffect(() => {
const id = setInterval(() => {
const t = now()
const idleFor = t - lastActivity.current
if (idleFor > IDLE_MS) {
sleeping.current = true
if (!bubble) setMood('sleeping')
return // resting — no nudges while she's away
}
// Active: time for a break?
if (t - sessionStart.current > BREAK_MS && t - lastBreak.current > BREAK_MS) {
lastBreak.current = t
sessionStart.current = t
say({ ...pick(BREAKS), tone: 'break' }, { proactive: true })
return
}
// Otherwise an occasional tip.
if (t - lastTip.current > TIP_MIN_GAP) {
lastTip.current = t
say({ ...pick(TIPS), tone: 'tip' }, { proactive: true })
}
}, 10_000)
return () => clearInterval(id)
}, [bubble, say])
useEffect(
() => () => {
clearTimeout(bubbleTimer.current)
clearTimeout(moodTimer.current)
},
[],
)
return { mood, bubble, dismiss }
}

View File

@@ -175,6 +175,50 @@ button, a, input {
pointer-events: none;
}
/* --- Companion kitten -------------------------------------------------------
The cozy corner mascot. Gently bobs while awake, settles and sways slowly
while napping; its speech bubble pops in; little zzz drift up when asleep. */
.petal-companion {
animation: petal-bob 3.2s ease-in-out infinite;
transition: transform 200ms ease;
}
.petal-companion:hover {
transform: translateY(-2px) scale(1.04);
}
.petal-companion-sleep {
animation: petal-bob-slow 5s ease-in-out infinite;
}
@keyframes petal-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@keyframes petal-bob-slow {
0%, 100% { transform: translateY(0) rotate(-1deg); }
50% { transform: translateY(-2px) rotate(1deg); }
}
.petal-bubble {
animation: petal-bubble-in 240ms cubic-bezier(0.2, 0.8, 0.3, 1.2) both;
transform-origin: bottom right;
}
@keyframes petal-bubble-in {
from { opacity: 0; transform: translateY(6px) scale(0.92); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.petal-zzz {
top: 2px;
right: 8px;
font-weight: 800;
font-size: 0.8rem;
animation: petal-zzz 2.4s ease-in-out infinite;
}
@keyframes petal-zzz {
0% { opacity: 0; transform: translateY(0) scale(0.8); }
30% { opacity: 0.9; }
100% { opacity: 0; transform: translateY(-14px) scale(1.1); }
}
/* Blinking caret in the Ask Petal bubble while awaiting the first token. */
.petal-chat-caret {
animation: petal-breathe 1s ease-in-out infinite;