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:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
49
web/src/components/Companion/companions.ts
Normal file
49
web/src/components/Companion/companions.ts
Normal 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'
|
||||||
Reference in New Issue
Block a user