Companion: selectable companions with a picker

Click the mascot to open a bilingual picker and switch between the Sleepy Cat
(always asleep, still talks in her sleep) and the Happy Dog (awake/bouncy).
Choice persists in localStorage.

- companions.ts is the roster; add one by dropping a pure-vector Lottie JSON
  in animations/ and appending a COMPANIONS entry.
- napping = companion.alwaysAsleep || mood === 'sleeping' drives the sway/zzz.
- Removed the old single MOOD_ANIMATIONS map (animations/index.ts).

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-25 22:07:48 -07:00
parent 97f99b27e4
commit 183219b5de
4 changed files with 156 additions and 37 deletions

View File

@@ -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] 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] 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] 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 ### Phase 7 — Spell check
- [ ] nspell browser-side (en-US), vendor dictionaries - [ ] nspell browser-side (en-US), vendor dictionaries

View File

@@ -1,7 +1,8 @@
import { useEffect, useRef, useState } from 'react'
import type { SaveStatus } from '../../hooks/useAutoSave' import type { SaveStatus } from '../../hooks/useAutoSave'
import { useCompanion, type Mood } from './useCompanion' import { useCompanion, type Mood } from './useCompanion'
import { LottiePlayer } from './LottiePlayer' import { LottiePlayer } from './LottiePlayer'
import { MOOD_ANIMATIONS } from './animations' import { COMPANIONS, DEFAULT_COMPANION } from './companions'
interface Props { interface Props {
wordCount: number wordCount: number
@@ -10,29 +11,113 @@ interface Props {
acceptTick: number 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<Mood, string> = { const MOOD_EMOJI: Record<Mood, string> = {
idle: '🐱', idle: '🐾',
happy: '😺', happy: '😺',
talking: '😸', talking: '😸',
celebrate: '😻', celebrate: '😻',
sleeping: '😴', 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 // 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) { export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }: Props) {
const { mood, bubble, dismiss } = useCompanion({ wordCount, saveStatus, editTick, acceptTick }) 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 const [companionId, setCompanionId] = useState<string>(() => {
// while she mumbles tips in her sleep. Flip to `mood === 'sleeping'` if you try {
// ever swap in an awake mascot. return localStorage.getItem(STORAGE_KEY) || DEFAULT_COMPANION
const napping = true } catch {
return DEFAULT_COMPANION
}
})
const companion = COMPANIONS.find((c) => c.id === companionId) ?? COMPANIONS[0]
const [pickerOpen, setPickerOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(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 ( return (
<div className="pointer-events-none fixed bottom-4 right-4 z-40 flex flex-col items-end gap-2"> <div
{bubble && ( ref={rootRef}
className="pointer-events-none fixed bottom-4 right-4 z-40 flex flex-col items-end gap-2"
>
{pickerOpen && (
<div
className="petal-bubble pointer-events-auto flex flex-col gap-0.5 p-1.5"
style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-card)',
boxShadow: 'var(--shadow-soft)',
fontFamily: 'var(--font-ui)',
minWidth: 180,
}}
>
<p
className="px-2 pb-1 pt-0.5 text-[0.7rem] font-bold"
style={{ color: 'var(--color-muted)' }}
>
· Choose a companion
</p>
{COMPANIONS.map((c) => {
const active = c.id === companion.id
return (
<button
key={c.id}
type="button"
onClick={() => choose(c.id)}
className="flex items-center gap-2.5 rounded-xl px-2 py-1.5 text-left text-sm transition-colors"
style={{
background: active ? 'var(--color-surface-alt)' : 'transparent',
color: 'var(--color-plum)',
}}
onMouseEnter={(e) => {
if (!active) e.currentTarget.style.background = 'var(--color-surface-alt)'
}}
onMouseLeave={(e) => {
if (!active) e.currentTarget.style.background = 'transparent'
}}
>
<span style={{ fontSize: 20, lineHeight: 1 }}>{c.emoji}</span>
<span className="flex-1">
<span className="font-bold">{c.zh}</span>{' '}
<span style={{ color: 'var(--color-muted)' }}>{c.name}</span>
</span>
{active && <span style={{ color: 'var(--color-accent)' }}></span>}
</button>
)
})}
</div>
)}
{bubble && !pickerOpen && (
<div <div
role="status" role="status"
onClick={dismiss} onClick={dismiss}
@@ -56,11 +141,16 @@ export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }:
</div> </div>
)} )}
<div <button
type="button"
onClick={() => setPickerOpen((o) => !o)}
title="Choose a companion"
aria-label="Choose a companion"
className={`petal-companion pointer-events-auto select-none ${napping ? 'petal-companion-sleep' : ''}`} className={`petal-companion pointer-events-auto select-none ${napping ? 'petal-companion-sleep' : ''}`}
style={{ style={{
width: 64, width: 64,
height: 64, height: 64,
padding: 0,
borderRadius: 'var(--radius-pill)', borderRadius: 'var(--radius-pill)',
background: 'var(--color-surface-alt)', background: 'var(--color-surface-alt)',
border: '1px solid var(--color-border)', border: '1px solid var(--color-border)',
@@ -69,10 +159,10 @@ export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }:
placeItems: 'center', placeItems: 'center',
position: 'relative', position: 'relative',
}} }}
aria-label="Petal companion"
> >
<LottiePlayer <LottiePlayer
animationData={MOOD_ANIMATIONS[mood]} key={companion.id}
animationData={companion.animations[mood]}
className="h-14 w-14" className="h-14 w-14"
fallback={<span style={{ fontSize: 32, lineHeight: 1 }}>{MOOD_EMOJI[mood]}</span>} fallback={<span style={{ fontSize: 32, lineHeight: 1 }}>{MOOD_EMOJI[mood]}</span>}
/> />
@@ -81,7 +171,7 @@ export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }:
z z
</span> </span>
)} )}
</div> </button>
</div> </div>
) )
} }

View File

@@ -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<Record<Mood, object>> = {
idle: sleepingCat,
happy: sleepingCat,
talking: sleepingCat,
celebrate: sleepingCat,
sleeping: sleepingCat,
}

View File

@@ -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<Record<Mood, object>>
// 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'