diff --git a/BUILD_PLAN.md b/BUILD_PLAN.md index 819a117..2cd84b9 100644 --- a/BUILD_PLAN.md +++ b/BUILD_PLAN.md @@ -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).** diff --git a/web/package-lock.json b/web/package-lock.json index 5781b47..fb0dbb3 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index f0ca96d..e88f304 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/App.tsx b/web/src/App.tsx index 0f62b84..e64a450 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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([]) @@ -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(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() { )} + + ) } diff --git a/web/src/components/Companion/LottiePlayer.tsx b/web/src/components/Companion/LottiePlayer.tsx new file mode 100644 index 0000000..df61aec --- /dev/null +++ b/web/src/components/Companion/LottiePlayer.tsx @@ -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(null) + const anim = useRef(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
+} diff --git a/web/src/components/Companion/PetalCompanion.tsx b/web/src/components/Companion/PetalCompanion.tsx new file mode 100644 index 0000000..f16add1 --- /dev/null +++ b/web/src/components/Companion/PetalCompanion.tsx @@ -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 = { + 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 ( +
+ {bubble && ( +
+

+ {bubble.zh} +

+

+ {bubble.en} +

+
+ )} + +
+ {MOOD_EMOJI[mood]}} + /> + {napping && ( + + z + + )} +
+
+ ) +} diff --git a/web/src/components/Companion/animations/index.ts b/web/src/components/Companion/animations/index.ts new file mode 100644 index 0000000..072408c --- /dev/null +++ b/web/src/components/Companion/animations/index.ts @@ -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> = { +// 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> = {} diff --git a/web/src/components/Companion/tips.ts b/web/src/components/Companion/tips.ts new file mode 100644 index 0000000..99d7df5 --- /dev/null +++ b/web/src/components/Companion/tips.ts @@ -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(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)] +} diff --git a/web/src/components/Companion/useCompanion.ts b/web/src/components/Companion/useCompanion.ts new file mode 100644 index 0000000..bd90d4f --- /dev/null +++ b/web/src/components/Companion/useCompanion.ts @@ -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('idle') + const [bubble, setBubble] = useState(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>(undefined) + const moodTimer = useRef>(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 } +} diff --git a/web/src/index.css b/web/src/index.css index 1aac43e..41b253c 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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;