Companion: wire real Lottie assets — the always-sleeping cat
- LottiePlayer now auto-crops each asset to its content bbox (union getBBox across 6 frames -> square viewBox), so stock files with empty artboard padding fill the badge instead of floating tiny in the middle. - Wire sleeping-cat.json to every mood: she's always asleep in the corner yet still mumbles tips and cheers through the bubble. napping hardcoded true so she keeps the calm sway + drifting zzz even while "talking". - happy-dog.json parked in the folder as an awake-mascot alternate. - Enable resolveJsonModule for the JSON imports. 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 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`.
|
- [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.)
|
||||||
|
|
||||||
### Phase 7 — Spell check
|
### Phase 7 — Spell check
|
||||||
- [ ] nspell browser-side (en-US), vendor dictionaries
|
- [ ] nspell browser-side (en-US), vendor dictionaries
|
||||||
|
|||||||
@@ -14,6 +14,37 @@ interface Props {
|
|||||||
fallback: React.ReactNode
|
fallback: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tighten the SVG viewBox to the animation's actual drawn content so an asset
|
||||||
|
// with a lot of empty artboard padding (common with stock Lottie files) fills
|
||||||
|
// the badge instead of floating tiny in the middle. We union the content bbox
|
||||||
|
// across a few frames (the mascot moves) and make it square so it isn't
|
||||||
|
// distorted by the square container.
|
||||||
|
function fitToContent(anim: AnimationItem, svg: SVGSVGElement) {
|
||||||
|
try {
|
||||||
|
const frames = anim.getDuration(true)
|
||||||
|
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity
|
||||||
|
const SAMPLES = 6
|
||||||
|
for (let i = 0; i < SAMPLES; i++) {
|
||||||
|
anim.goToAndStop(Math.floor(((frames - 1) * i) / (SAMPLES - 1)), true)
|
||||||
|
const b = svg.getBBox()
|
||||||
|
if (b.width === 0 || b.height === 0) continue
|
||||||
|
x1 = Math.min(x1, b.x)
|
||||||
|
y1 = Math.min(y1, b.y)
|
||||||
|
x2 = Math.max(x2, b.x + b.width)
|
||||||
|
y2 = Math.max(y2, b.y + b.height)
|
||||||
|
}
|
||||||
|
if (!isFinite(x1)) return
|
||||||
|
const cx = (x1 + x2) / 2, cy = (y1 + y2) / 2
|
||||||
|
const side = Math.max(x2 - x1, y2 - y1) * 1.14 // ~7% breathing room each side
|
||||||
|
svg.setAttribute('viewBox', `${cx - side / 2} ${cy - side / 2} ${side} ${side}`)
|
||||||
|
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet')
|
||||||
|
} catch {
|
||||||
|
/* getBBox unsupported / not laid out — leave the original viewBox */
|
||||||
|
} finally {
|
||||||
|
anim.goToAndPlay(0, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A thin wrapper over lottie-web (framework-agnostic, no React peer deps, fully
|
// A thin wrapper over lottie-web (framework-agnostic, no React peer deps, fully
|
||||||
// offline). Reloads the animation whenever the data changes — moods swap by
|
// offline). Reloads the animation whenever the data changes — moods swap by
|
||||||
// passing a different `animationData`.
|
// passing a different `animationData`.
|
||||||
@@ -22,16 +53,22 @@ export function LottiePlayer({ animationData, loop = true, className, fallback }
|
|||||||
const anim = useRef<AnimationItem | null>(null)
|
const anim = useRef<AnimationItem | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref.current || !animationData) return
|
const container = ref.current
|
||||||
anim.current = lottie.loadAnimation({
|
if (!container || !animationData) return
|
||||||
container: ref.current,
|
const item = lottie.loadAnimation({
|
||||||
|
container,
|
||||||
renderer: 'svg',
|
renderer: 'svg',
|
||||||
loop,
|
loop,
|
||||||
autoplay: true,
|
autoplay: true,
|
||||||
animationData,
|
animationData,
|
||||||
})
|
})
|
||||||
|
anim.current = item
|
||||||
|
item.addEventListener('DOMLoaded', () => {
|
||||||
|
const svg = container.querySelector('svg')
|
||||||
|
if (svg) fitToContent(item, svg as SVGSVGElement)
|
||||||
|
})
|
||||||
return () => {
|
return () => {
|
||||||
anim.current?.destroy()
|
item.destroy()
|
||||||
anim.current = null
|
anim.current = null
|
||||||
}
|
}
|
||||||
}, [animationData, loop])
|
}, [animationData, loop])
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ const MOOD_EMOJI: Record<Mood, string> = {
|
|||||||
// break reminders. Animation is Lottie when assets exist, emoji otherwise.
|
// break reminders. Animation is Lottie when assets exist, emoji otherwise.
|
||||||
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 })
|
||||||
const napping = mood === 'sleeping'
|
// 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
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none fixed bottom-4 right-4 z-40 flex flex-col items-end gap-2">
|
<div className="pointer-events-none fixed bottom-4 right-4 z-40 flex flex-col items-end gap-2">
|
||||||
|
|||||||
1
web/src/components/Companion/animations/happy-dog.json
Normal file
1
web/src/components/Companion/animations/happy-dog.json
Normal file
File diff suppressed because one or more lines are too long
@@ -1,21 +1,20 @@
|
|||||||
import type { Mood } from '../useCompanion'
|
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. Until you add files the companion
|
// Lottie animation assets, keyed by mood. Assets are pure vector (no
|
||||||
// falls back to an emoji kitten that still bobs, naps, and reacts — so every
|
// expressions/images) so they render on the lottie-web light build, fully
|
||||||
// behavior works today; this map is the ONLY thing to change when assets land.
|
// offline; LottiePlayer auto-crops each to its content bbox.
|
||||||
//
|
//
|
||||||
// To wire a real kitten:
|
// Per the resident feline: the cat is ALWAYS asleep. Every mood maps to the
|
||||||
// 1. Grab a Lottie cat JSON (e.g. from lottiefiles.com) and save it here,
|
// same sleeping-cat loop, so she snoozes peacefully in the corner — and yet
|
||||||
// e.g. ./kitten-idle.json. Prefer plain Lottie JSON (not .lottie) so it
|
// still somehow talks in her sleep, dispensing tips and encouragement through
|
||||||
// bundles offline with no runtime CDN fetch.
|
// the speech bubble. (Yes, this is intentional. It's funnier this way.)
|
||||||
// 2. Import and map it:
|
export const MOOD_ANIMATIONS: Partial<Record<Mood, object>> = {
|
||||||
// import idle from './kitten-idle.json'
|
idle: sleepingCat,
|
||||||
// import happy from './kitten-happy.json'
|
happy: sleepingCat,
|
||||||
// export const MOOD_ANIMATIONS: Partial<Record<Mood, object>> = {
|
talking: sleepingCat,
|
||||||
// idle, happy, talking: happy, celebrate: happy, sleeping: idle,
|
celebrate: sleepingCat,
|
||||||
// }
|
sleeping: sleepingCat,
|
||||||
// 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>> = {}
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -7,6 +7,7 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user