Add butterfly/parrot/wiggle-dog companions; scale mascot + tips
- Three new Lottie companions: Wiggle Dog (摇尾狗), Butterfly (蝴蝶), Parrot (鹦鹉). Parrot is mirrored via a new `flip` flag on Companion, threaded through LottiePlayer (scaleX(-1)) since the asset faces left. - Mascot size now scales with the viewport: --petal-companion-size clamp(10rem, 17vw, 20rem); art, emoji fallback, and the sleep "z" all derive from it. - Larger, more legible tip bubble (1.4rem zh / 1.15rem en, wider bubble). - Bubbles linger longer for a bilingual ESL read: baseline 9s→14s, cheer 6s→9s, per-char 45→55ms, cap 20s→32s. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
@@ -11,6 +11,8 @@ interface Props {
|
||||
animationData?: object
|
||||
loop?: boolean
|
||||
className?: string
|
||||
// Mirror the animation left↔right (for assets drawn facing the wrong way).
|
||||
flip?: boolean
|
||||
fallback: React.ReactNode
|
||||
}
|
||||
|
||||
@@ -48,7 +50,7 @@ function fitToContent(anim: AnimationItem, svg: SVGSVGElement) {
|
||||
// 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) {
|
||||
export function LottiePlayer({ animationData, loop = true, className, flip, fallback }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const anim = useRef<AnimationItem | null>(null)
|
||||
|
||||
@@ -74,5 +76,12 @@ export function LottiePlayer({ animationData, loop = true, className, fallback }
|
||||
}, [animationData, loop])
|
||||
|
||||
if (!animationData) return <>{fallback}</>
|
||||
return <div ref={ref} className={className} aria-hidden />
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={className}
|
||||
style={flip ? { transform: 'scaleX(-1)' } : undefined}
|
||||
aria-hidden
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
|
||||
onClick={dismiss}
|
||||
onMouseEnter={holdBubble}
|
||||
onMouseLeave={releaseBubble}
|
||||
className="petal-bubble pointer-events-auto max-w-[260px] cursor-pointer p-3 pr-3.5"
|
||||
className="petal-bubble pointer-events-auto max-w-[420px] cursor-pointer p-4 pr-5"
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
@@ -158,10 +158,16 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
|
||||
}}
|
||||
title="Click to dismiss"
|
||||
>
|
||||
<p className="text-sm font-bold leading-snug" style={{ color: 'var(--color-plum)' }}>
|
||||
<p
|
||||
className="font-bold leading-snug"
|
||||
style={{ color: 'var(--color-plum)', fontSize: '1.4rem' }}
|
||||
>
|
||||
{bubble.zh}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs leading-snug" style={{ color: 'var(--color-muted)' }}>
|
||||
<p
|
||||
className="mt-0.5 leading-snug"
|
||||
style={{ color: 'var(--color-muted)', fontSize: '1.15rem' }}
|
||||
>
|
||||
{bubble.en}
|
||||
</p>
|
||||
</div>
|
||||
@@ -174,8 +180,9 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
|
||||
aria-label="Choose a companion"
|
||||
className={`petal-companion pointer-events-auto select-none ${napping ? 'petal-companion-sleep' : ''}`}
|
||||
style={{
|
||||
width: 144,
|
||||
height: 144,
|
||||
// Size scales with the viewport — see --petal-companion-size in index.css.
|
||||
width: 'var(--petal-companion-size)',
|
||||
height: 'var(--petal-companion-size)',
|
||||
padding: 0,
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
background: 'var(--color-surface-alt)',
|
||||
@@ -189,8 +196,13 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
|
||||
<LottiePlayer
|
||||
key={companion.id}
|
||||
animationData={animationData}
|
||||
className="h-32 w-32"
|
||||
fallback={<span style={{ fontSize: 72, lineHeight: 1 }}>{MOOD_EMOJI[renderMood]}</span>}
|
||||
flip={companion.flip}
|
||||
className="petal-companion-art"
|
||||
fallback={
|
||||
<span style={{ fontSize: 'calc(var(--petal-companion-size) * 0.5)', lineHeight: 1 }}>
|
||||
{MOOD_EMOJI[renderMood]}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{napping && (
|
||||
<span className="petal-zzz absolute" aria-hidden style={{ color: 'var(--color-muted)' }}>
|
||||
|
||||
1
web/src/components/Companion/animations/butterfly.json
Normal file
1
web/src/components/Companion/animations/butterfly.json
Normal file
File diff suppressed because one or more lines are too long
1
web/src/components/Companion/animations/parrot.json
Normal file
1
web/src/components/Companion/animations/parrot.json
Normal file
File diff suppressed because one or more lines are too long
1
web/src/components/Companion/animations/wiggle-dog.json
Normal file
1
web/src/components/Companion/animations/wiggle-dog.json
Normal file
File diff suppressed because one or more lines are too long
@@ -1,6 +1,9 @@
|
||||
import type { Mood } from './useCompanion'
|
||||
import sleepingCat from './animations/sleeping-cat.json'
|
||||
import happyDog from './animations/happy-dog.json'
|
||||
import wiggleDog from './animations/wiggle-dog.json'
|
||||
import butterfly from './animations/butterfly.json'
|
||||
import parrot from './animations/parrot.json'
|
||||
|
||||
export interface Companion {
|
||||
id: string
|
||||
@@ -12,6 +15,9 @@ export interface Companion {
|
||||
// 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
|
||||
// When true the rendered animation is mirrored left↔right — for assets drawn
|
||||
// facing the "wrong" way for our bottom-right corner (e.g. the parrot).
|
||||
flip?: boolean
|
||||
}
|
||||
|
||||
// The roster. Add a companion by dropping a pure-vector Lottie JSON into
|
||||
@@ -44,6 +50,43 @@ export const COMPANIONS: Companion[] = [
|
||||
// no sleeping clip → stays in its idle pose instead of visibly napping
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'wiggle-dog',
|
||||
name: 'Wiggle Dog',
|
||||
zh: '摇尾狗',
|
||||
emoji: '🐕',
|
||||
animations: {
|
||||
idle: wiggleDog,
|
||||
happy: wiggleDog,
|
||||
talking: wiggleDog,
|
||||
celebrate: wiggleDog,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'butterfly',
|
||||
name: 'Butterfly',
|
||||
zh: '蝴蝶',
|
||||
emoji: '🦋',
|
||||
animations: {
|
||||
idle: butterfly,
|
||||
happy: butterfly,
|
||||
talking: butterfly,
|
||||
celebrate: butterfly,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'parrot',
|
||||
name: 'Parrot',
|
||||
zh: '鹦鹉',
|
||||
emoji: '🦜',
|
||||
flip: true, // asset faces left; mirror it to face into the page
|
||||
animations: {
|
||||
idle: parrot,
|
||||
happy: parrot,
|
||||
talking: parrot,
|
||||
celebrate: parrot,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export const DEFAULT_COMPANION = 'cat'
|
||||
|
||||
@@ -47,10 +47,10 @@ const PROACTIVE_GAP = 40_000 // floor between any two unsolicited bubbles
|
||||
// How long a bubble lingers. These are *floors* — readBubbleMs extends them by
|
||||
// how much there is to read, since she reads both the Mandarin and the English
|
||||
// (and tips now quote a slice of her own sentence, so they run longer).
|
||||
const BUBBLE_MS = 9_000 // baseline for a tip / break
|
||||
const CHEER_MS = 6_000 // a short cheer still needs a beat in two languages
|
||||
const READ_MS_PER_CHAR = 45 // ~22 chars/sec, generous for a bilingual read
|
||||
const MAX_BUBBLE_MS = 20_000 // cap so a long quote can't pin the bubble forever
|
||||
const BUBBLE_MS = 14_000 // baseline for a tip / break
|
||||
const CHEER_MS = 9_000 // a short cheer still needs a beat in two languages
|
||||
const READ_MS_PER_CHAR = 55 // ~18 chars/sec, generous for a bilingual ESL read
|
||||
const MAX_BUBBLE_MS = 32_000 // cap so a long quote can't pin the bubble forever
|
||||
const HOVER_GRACE_MS = 3_000 // lingers this long after she stops hovering
|
||||
const now = () => Date.now()
|
||||
|
||||
|
||||
@@ -319,9 +319,17 @@ button, a, input {
|
||||
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 {
|
||||
/* Mascot size scales with the viewport width: ~original on a laptop, up to
|
||||
~2× on a large desktop. Tune the middle (vw) term to taste. */
|
||||
--petal-companion-size: clamp(10rem, 17vw, 20rem);
|
||||
animation: petal-bob 3.2s ease-in-out infinite;
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
/* The Lottie art sits inside the round badge with a little breathing room. */
|
||||
.petal-companion-art {
|
||||
width: 88%;
|
||||
height: 88%;
|
||||
}
|
||||
.petal-companion:hover {
|
||||
transform: translateY(-2px) scale(1.04);
|
||||
}
|
||||
@@ -347,10 +355,10 @@ button, a, input {
|
||||
}
|
||||
|
||||
.petal-zzz {
|
||||
top: 6px;
|
||||
right: 14px;
|
||||
top: calc(var(--petal-companion-size) * 0.04);
|
||||
right: calc(var(--petal-companion-size) * 0.1);
|
||||
font-weight: 800;
|
||||
font-size: 1.1rem;
|
||||
font-size: calc(var(--petal-companion-size) * 0.12);
|
||||
animation: petal-zzz 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes petal-zzz {
|
||||
|
||||
Reference in New Issue
Block a user