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:
@@ -65,6 +65,7 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
|
||||
- [x] Shape language, shadows, transitions — `--radius-*`, `--shadow-soft`, global `200ms ease` on interactive elements
|
||||
- [x] Signature animations (suggestion fade-float, accept confetti, breathing checkpoint dot) — fade-float + breathing dot already live; **accept confetti** added this phase: CSS-only 4-dot burst (`petal-confetti`/`@keyframes petal-confetti`, direction via `--dx`/`--dy` inline), spawned in `EditorCore.handleAccept` at the card position, auto-cleared after 720ms
|
||||
- [x] Distraction-free mode — entered on editor focus (`EditorCore` `onFocus` → `App.setFocusMode`), the doc-list sidebar slides left + collapses to 0 width (`.petal-sidebar`/`.petal-sidebar-hidden`, 280ms), editor canvas re-centers full-width. Restored by Escape or a pointer-down outside the centered canvas (gutters, header, status bar via `handleChromeDown` + `canvasRef` containment check)
|
||||
- [x] **Companion kitten** (`web/src/components/Companion/`) — cozy corner mascot that reacts to the writing session. `useCompanion` is the library-agnostic behavior engine (cheers on accept/milestones, Mandarin-first writing tips, screen-break reminders after a long stretch, idle naps + welcome-back); `PetalCompanion` renders the kitten + a CJK-first speech bubble (zh prominent, en subtitle — Note #17). Animation via `lottie-web/build/player/lottie_light` (offline, no eval/CDN) behind a `LottiePlayer` wrapper; **emoji placeholder per mood until a Lottie asset is dropped into `animations/index.ts`** (the single swap seam). App feeds it `editTick`/`acceptTick` counters + `wordCount`/`saveStatus`. All copy bilingual in `tips.ts`.
|
||||
|
||||
### Phase 7 — Spell check
|
||||
- [ ] nspell browser-side (en-US), vendor dictionaries
|
||||
@@ -81,5 +82,6 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
|
||||
- 2026-06-25: **Phase 2 complete.** Backend `internal/docs`: chi sub-router (list/create/get/update/delete) mounted at `/api/docs`, local-user scoped, RETURNING on create, COALESCE partial-update (one PUT serves rename + full save), 404/400 JSON errors; `handlers_test.go` walks the full lifecycle. Frontend: `api/client.ts`, `useAutoSave` (1.5s debounce + `saveNow` flush), `EditorCore` (Tiptap StarterKit/Underline/TextAlign/Placeholder/CharacterCount) + `Toolbar`, `DocList`/`DocListItem`, `StatusBar`, rewritten `App.tsx` orchestrating load/select/create/delete with optimistic sidebar patching. `.petal-prose` styles (Lora body, Nunito headings). tsc clean, vite build OK, go build OK; smoke-tested full CRUD incl. CJK title round-trip + SPA serve. Next: **Phase 3 (LLM grammar checkpoint).**
|
||||
- 2026-06-25: **Phase 3 complete.** Backend `internal/llm`: `LLMClient` interface + factory (vLLM OpenAI-compat + Ollama native, both Complete/Stream), `prompts.go` (checkpoint + Ask Petal templates), `checkpoint.go` (brace-matched JSON salvage, per-doc 30s `RateLimiter`, doc/history truncation). `internal/suggestions`: `/api/docs/:id/check` + `:id/suggestions` + `/api/suggestions/:id/{accept,dismiss}`; each check replaces the pending set in a tx (accepted/rejected kept as history), throttled checks return the current set, positions located by `strings.Index` (advisory only). Frontend: `useCheckpoint` (4s debounce, loads existing on doc open, run-token guards stale responses), `SuggestionHighlight` Tiptap extension rendering ProseMirror **decorations** re-anchored by `original` string on every doc change (precise textblock offset→PM-pos mapping, handles inline atoms), `SuggestionCard` (type-colored tag, original→replacement diff, accept applies replacement in-editor + PATCHes, hover-bridge with close delay), breathing rose checkpoint dot in StatusBar, suggestion fade-float + breathe CSS. Tests: llm parse/rate-limit/truncate, suggestions full flow + rate-limit over httptest with a stub client. go build/vet/test clean, tsc clean, vite build OK; end-to-end smoke-tested against a fake vLLM endpoint (anchoring verified: `I has`→0:5, `two apple`→6:15) and 502 path when LLM unreachable. Next: **Phase 4 (Ask Petal SSE chat).**
|
||||
- 2026-06-25: **Phase 5 complete.** Tier-1 voice-consistency pass. Backend: `internal/llm/voice.go` (`RunVoice` — whole document, no `TruncateDoc`, MaxTokens 2048, `VoiceInterval` 20s per-doc floor), standalone `voiceSystemPrompt`/`VoiceMessages` (not bundled with the grammar checkpoint). `internal/suggestions`: `POST /api/docs/:id/voice` route; `check`/`voice` collapsed into a shared `runPass(limiter, pass, scope)`; `pendingScope` makes `replacePending` family-aware (grammar deletes `type != 'voice'`, voice deletes `type = 'voice'`), so the two passes never clobber each other's pending flags; both endpoints now return the **unified** pending set (also fixed a latent throttle-returns-full-set vs success-returns-batch inconsistency). Frontend: `api.voiceDoc`, `useCheckpoint` → `voicing`/`runVoice` (shared run-token guard, reset on doc switch), honey "Check my voice 🍯" pill in `Toolbar` (→ "Reading…" while in flight), breathing honey dot + "Reading your voice…" in `StatusBar`. Voice flags' `replacement: null` round-trips to `""`; `SuggestionCard` already hides the diff row + Accept for those. Tests: `TestVoicePassCoexists` (coexistence both directions, unified response, null→"" replacement). go build/vet/test clean, tsc clean, vite build OK. Live smoke vs a fake vLLM: grammar check → grammar flag; voice pass → unified `[grammar@0, voice@62 (empty replacement)]`, grammar preserved. Known limitation: `findRange` is single-textblock, so a voice passage crossing a `\n\n` paragraph break won't decorate (deferred). Next: **Phase 6 (design system & polish).**
|
||||
- 2026-06-25: **Companion kitten added** (Phase 6 extra, per user request). A cozy corner mascot that gives feedback and gentle nudges. `web/src/components/Companion/`: `useCompanion` (behavior engine — cheer on accept/word-count milestones, Mandarin-first writing tips on a paced timer, screen-break reminder after ~25min continuous writing, idle nap after ~75s + welcome-back; priority/cooldown so it never nags), `tips.ts` (all copy bilingual, zh-first), `LottiePlayer` (wraps `lottie-web` **light** build — offline, no eval/CDN fetch, so it bundles into the Go binary), `PetalCompanion` (kitten + CJK-first speech bubble). **Ships working today with an emoji-kitten placeholder** (😺/😻/😴 per mood, CSS bob/nap/zzz); dropping a Lottie cat JSON into `animations/index.ts` is the only change to upgrade to real animation. Library decision: Lottie via `lottie-web` (not the React wrapper → no React 19 peer-dep friction; not dotLottie → no runtime CDN/wasm, stays offline-embeddable). App wires `editTick`/`acceptTick`/`wordCount`/`saveStatus`. tsc clean, vite build OK (light build trimmed ~34KB gzip vs full + removed eval warning), go build/vet/test clean. Verified headless: greeting bubble on load (“嗨~我在这儿陪你写作哦” + EN subtitle), heart-eyes celebrate + “我很喜欢这个改法 💕” on accept. **TODO (user):** source a Lottie cat asset to replace the emoji placeholder.
|
||||
- 2026-06-25: **Phase 6 complete.** Design system & polish. Tokens/fonts/shape/transitions were already in place from Phase 0; this phase added the two missing signature pieces. **Accept confetti**: CSS-only burst (`.petal-confetti-dot` + `@keyframes petal-confetti`, each dot's trajectory from inline `--dx`/`--dy`), a `Confetti` component in `EditorCore` spawned at the accepted card's position on `handleAccept` and cleared after 720ms (timer cleaned up on unmount). **Distraction-free mode**: `EditorCore` gains an `onFocus` → `App` `focusMode` state; the doc-list sidebar is wrapped in `.petal-sidebar` and collapses via `.petal-sidebar-hidden` (width→0 + translateX + fade, 280ms) while the centered editor canvas re-centers into the full pane; restored by Escape (window keydown) or a pointer-down outside the canvas (`handleChromeDown` checks `canvasRef` containment; wired on the header, the editor scroll-gutter, and the status bar). tsc clean, vite build OK, go build/vet/test clean; binary boots and serves the rebuilt SPA with the new CSS embedded (confetti + sidebar-collapse classes verified in the served bundle). Next: **Phase 7 (browser-side spell check, nspell en-US).**
|
||||
- 2026-06-25: **Phase 4 complete.** Backend: `internal/llm/chat.go` (`StreamAskPetal` — conversational sampling params, reuses `AskPetalSystemPrompt`/`TrimHistory`), `internal/suggestions/chat.go` (`POST /api/suggestions/:id/chat` — one user-scoped join loads the suggestion + parent `content_text`, `surroundingParagraph` extracts the `\n\n`-bounded paragraph at `from_pos` with whole-doc fallback, streams `event: token`/`event: done` SSE frames with JSON-encoded data, `X-Accel-Buffering: no`, real `http.Flusher` per chunk; LLM-down → 502 before SSE headers, unknown id → 404). Handler imports the interface only. Frontend: `streamSuggestionChat` (fetch + ReadableStream SSE parser, abortable), `AskPetal.tsx` (in-component history — no persistence, pre-seeded first bubble, rose/lavender bubbles, CJK font stack per Note #17, streaming caret), `SuggestionCard` "Ask Petal ✨" pill that pins the card open (hover-close suppressed, click-away closes) and widens it to 340px. Tests: `chat_test.go` (streamed-text concat + done event, server-side context injection asserted on the system message, sampling params, 404, `surroundingParagraph` unit). go build/vet/test clean, tsc clean, vite build OK. Live SSE smoke test against a fake streaming vLLM (fresh ports 8077/8088 — a pre-existing dev petal on :8099 left untouched): tokens flushed individually through the chi middleware stack, `done` terminator, 502 on LLM-down, 404 on unknown suggestion all verified. Next: **Phase 5 (voice consistency pass, Tier 1).**
|
||||
|
||||
7
web/package-lock.json
generated
7
web/package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"@tiptap/pm": "^2.11.5",
|
||||
"@tiptap/react": "^2.11.5",
|
||||
"@tiptap/starter-kit": "^2.11.5",
|
||||
"lottie-web": "^5.13.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
@@ -2605,6 +2606,12 @@
|
||||
"uc.micro": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lottie-web": {
|
||||
"version": "5.13.0",
|
||||
"resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz",
|
||||
"integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
|
||||
@@ -9,15 +9,16 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"@tiptap/react": "^2.11.5",
|
||||
"@tiptap/pm": "^2.11.5",
|
||||
"@tiptap/starter-kit": "^2.11.5",
|
||||
"@tiptap/extension-underline": "^2.11.5",
|
||||
"@tiptap/extension-text-align": "^2.11.5",
|
||||
"@tiptap/extension-character-count": "^2.11.5",
|
||||
"@tiptap/extension-placeholder": "^2.11.5",
|
||||
"@tiptap/extension-character-count": "^2.11.5"
|
||||
"@tiptap/extension-text-align": "^2.11.5",
|
||||
"@tiptap/extension-underline": "^2.11.5",
|
||||
"@tiptap/pm": "^2.11.5",
|
||||
"@tiptap/react": "^2.11.5",
|
||||
"@tiptap/starter-kit": "^2.11.5",
|
||||
"lottie-web": "^5.13.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user