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:
41
web/src/components/Companion/LottiePlayer.tsx
Normal file
41
web/src/components/Companion/LottiePlayer.tsx
Normal 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 />
|
||||
}
|
||||
83
web/src/components/Companion/PetalCompanion.tsx
Normal file
83
web/src/components/Companion/PetalCompanion.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
21
web/src/components/Companion/animations/index.ts
Normal file
21
web/src/components/Companion/animations/index.ts
Normal 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>> = {}
|
||||
63
web/src/components/Companion/tips.ts
Normal file
63
web/src/components/Companion/tips.ts
Normal 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 — you’ve got this!' },
|
||||
{ zh: '嗯嗯,这样清楚多了 👍', en: 'Mm, that’s 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: '复数别忘了加 s:two 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 ✨ let’s 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)]
|
||||
}
|
||||
182
web/src/components/Companion/useCompanion.ts
Normal file
182
web/src/components/Companion/useCompanion.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user