diff --git a/BUILD_PLAN.md b/BUILD_PLAN.md index f1b060b..07c7810 100644 --- a/BUILD_PLAN.md +++ b/BUILD_PLAN.md @@ -65,7 +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 mascot** (`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 it + 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 that **auto-crops the asset to its content bbox** (unions getBBox across 6 frames → square viewBox) so stock files with empty artboard padding fill the badge. **Real asset wired: `animations/sleeping-cat.json`** — every mood maps to the same sleeping-cat loop, so she's *always* asleep in the corner yet still mumbles tips/cheers through the bubble (a deliberate gag the user loved). `napping` is hardcoded true in `PetalCompanion` so she keeps the calm sway + drifting zzz even while talking. A `happy-dog.json` (also pure vector) is kept in the folder as an awake-mascot option. `animations/index.ts` is the single swap seam. App feeds it `editTick`/`acceptTick` + `wordCount`/`saveStatus`. All copy bilingual in `tips.ts`. (`resolveJsonModule` enabled in tsconfig for the JSON import.) +- [x] **Companion mascot** (`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 it + 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 that **auto-crops the asset to its content bbox** (unions getBBox across 6 frames → square viewBox) so stock files with empty artboard padding fill the badge. **Selectable companions** (`companions.ts` roster): clicking the mascot opens a bilingual picker ("选个小伙伴 · Choose a companion") to switch between **瞌睡猫 Sleepy Cat** (`sleeping-cat.json`, `alwaysAsleep` → every mood maps to the sleeping loop, so she snoozes yet still mumbles tips/cheers — a deliberate gag) and **开心狗 Happy Dog** (`happy-dog.json`, awake/bouncy; naps via 😴 emoji). Choice persists in `localStorage` (`petal.companion`). Add a companion = drop a pure-vector Lottie JSON in `animations/` + append a `COMPANIONS` entry. `napping = companion.alwaysAsleep || mood === 'sleeping'` drives the sway/zzz. Each asset is auto-cropped to its content bbox by `LottiePlayer`. App feeds it `editTick`/`acceptTick` + `wordCount`/`saveStatus`. All copy bilingual in `tips.ts`. (`resolveJsonModule` enabled in tsconfig for the JSON import.) ### Phase 7 — Spell check - [ ] nspell browser-side (en-US), vendor dictionaries diff --git a/web/src/components/Companion/PetalCompanion.tsx b/web/src/components/Companion/PetalCompanion.tsx index 1df8ce3..5bbc927 100644 --- a/web/src/components/Companion/PetalCompanion.tsx +++ b/web/src/components/Companion/PetalCompanion.tsx @@ -1,7 +1,8 @@ +import { useEffect, useRef, useState } from 'react' import type { SaveStatus } from '../../hooks/useAutoSave' import { useCompanion, type Mood } from './useCompanion' import { LottiePlayer } from './LottiePlayer' -import { MOOD_ANIMATIONS } from './animations' +import { COMPANIONS, DEFAULT_COMPANION } from './companions' interface Props { wordCount: number @@ -10,29 +11,113 @@ interface Props { acceptTick: number } -// Emoji placeholder per mood, used until a Lottie asset is wired for that mood. +// Emoji placeholder per mood, used for any mood a companion has no Lottie for. const MOOD_EMOJI: Record = { - idle: '🐱', + idle: '🐾', happy: '😺', talking: '😸', celebrate: '😻', sleeping: '😴', } -// PetalCompanion is the cozy corner kitten: it watches the writing session via +const STORAGE_KEY = 'petal.companion' + +// PetalCompanion is the cozy corner mascot: it watches the writing session via // useCompanion and shows a Mandarin-first speech bubble for cheers, tips, and -// break reminders. Animation is Lottie when assets exist, emoji otherwise. +// break reminders. Clicking the mascot opens a picker to switch companions +// (the choice persists in localStorage). export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }: Props) { const { mood, bubble, dismiss } = useCompanion({ wordCount, saveStatus, editTick, acceptTick }) - // This mascot is always asleep (every mood maps to the sleeping-cat loop, see - // animations/index.ts) — so she always gets the calm sway + drifting zzz, even - // while she mumbles tips in her sleep. Flip to `mood === 'sleeping'` if you - // ever swap in an awake mascot. - const napping = true + + const [companionId, setCompanionId] = useState(() => { + try { + return localStorage.getItem(STORAGE_KEY) || DEFAULT_COMPANION + } catch { + return DEFAULT_COMPANION + } + }) + const companion = COMPANIONS.find((c) => c.id === companionId) ?? COMPANIONS[0] + const [pickerOpen, setPickerOpen] = useState(false) + const rootRef = useRef(null) + + // Always-asleep mascots keep the calm sway + zzz whatever the engine's mood. + const napping = companion.alwaysAsleep || mood === 'sleeping' + + function choose(id: string) { + setCompanionId(id) + try { + localStorage.setItem(STORAGE_KEY, id) + } catch { + /* private mode / storage disabled — selection just won't persist */ + } + setPickerOpen(false) + } + + // Click outside the corner closes the picker. + useEffect(() => { + if (!pickerOpen) return + const onDown = (e: MouseEvent) => { + if (!rootRef.current?.contains(e.target as Node)) setPickerOpen(false) + } + document.addEventListener('mousedown', onDown) + return () => document.removeEventListener('mousedown', onDown) + }, [pickerOpen]) return ( -
- {bubble && ( +
+ {pickerOpen && ( +
+

+ 选个小伙伴 · Choose a companion +

+ {COMPANIONS.map((c) => { + const active = c.id === companion.id + return ( + + ) + })} +
+ )} + + {bubble && !pickerOpen && (
)} -
setPickerOpen((o) => !o)} + title="Choose a companion" + aria-label="Choose a companion" className={`petal-companion pointer-events-auto select-none ${napping ? 'petal-companion-sleep' : ''}`} style={{ width: 64, height: 64, + padding: 0, borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', border: '1px solid var(--color-border)', @@ -69,10 +159,10 @@ export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }: placeItems: 'center', position: 'relative', }} - aria-label="Petal companion" > {MOOD_EMOJI[mood]}} /> @@ -81,7 +171,7 @@ export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }: z )} -
+
) } diff --git a/web/src/components/Companion/animations/index.ts b/web/src/components/Companion/animations/index.ts deleted file mode 100644 index 97b09e6..0000000 --- a/web/src/components/Companion/animations/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Mood } from '../useCompanion' -import sleepingCat from './sleeping-cat.json' -// A Happy Dog clip is also in this folder (happy-dog.json) if you ever want an -// awake, bouncy mascot — import it and map it to the awake/active moods below. - -// Lottie animation assets, keyed by mood. Assets are pure vector (no -// expressions/images) so they render on the lottie-web light build, fully -// offline; LottiePlayer auto-crops each to its content bbox. -// -// Per the resident feline: the cat is ALWAYS asleep. Every mood maps to the -// same sleeping-cat loop, so she snoozes peacefully in the corner — and yet -// still somehow talks in her sleep, dispensing tips and encouragement through -// the speech bubble. (Yes, this is intentional. It's funnier this way.) -export const MOOD_ANIMATIONS: Partial> = { - idle: sleepingCat, - happy: sleepingCat, - talking: sleepingCat, - celebrate: sleepingCat, - sleeping: sleepingCat, -} diff --git a/web/src/components/Companion/companions.ts b/web/src/components/Companion/companions.ts new file mode 100644 index 0000000..e9b1a63 --- /dev/null +++ b/web/src/components/Companion/companions.ts @@ -0,0 +1,49 @@ +import type { Mood } from './useCompanion' +import sleepingCat from './animations/sleeping-cat.json' +import happyDog from './animations/happy-dog.json' + +export interface Companion { + id: string + name: string // English label + zh: string // Chinese label (she reads Mandarin — north star Note #17) + emoji: string // shown in the picker + used as the per-mood fallback base + // mood → Lottie asset. Unmapped moods fall back to the per-mood emoji. + animations: Partial> + // When true the mascot keeps its calm sway + drifting zzz regardless of mood + // (e.g. a cat that's always asleep but still talks in its sleep). + alwaysAsleep?: boolean +} + +// The roster. Add a companion by dropping a pure-vector Lottie JSON into +// ./animations and appending an entry here — that's the whole change. +export const COMPANIONS: Companion[] = [ + { + id: 'cat', + name: 'Sleepy Cat', + zh: '瞌睡猫', + emoji: '😴', + alwaysAsleep: true, + animations: { + idle: sleepingCat, + happy: sleepingCat, + talking: sleepingCat, + celebrate: sleepingCat, + sleeping: sleepingCat, + }, + }, + { + id: 'dog', + name: 'Happy Dog', + zh: '开心狗', + emoji: '🐶', + animations: { + idle: happyDog, + happy: happyDog, + talking: happyDog, + celebrate: happyDog, + // no sleeping clip → naps with the 😴 emoji fallback + }, + }, +] + +export const DEFAULT_COMPANION = 'cat'