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
|
animationData?: object
|
||||||
loop?: boolean
|
loop?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
|
// Mirror the animation left↔right (for assets drawn facing the wrong way).
|
||||||
|
flip?: boolean
|
||||||
fallback: React.ReactNode
|
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
|
// 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`.
|
||||||
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 ref = useRef<HTMLDivElement>(null)
|
||||||
const anim = useRef<AnimationItem | null>(null)
|
const anim = useRef<AnimationItem | null>(null)
|
||||||
|
|
||||||
@@ -74,5 +76,12 @@ export function LottiePlayer({ animationData, loop = true, className, fallback }
|
|||||||
}, [animationData, loop])
|
}, [animationData, loop])
|
||||||
|
|
||||||
if (!animationData) return <>{fallback}</>
|
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}
|
onClick={dismiss}
|
||||||
onMouseEnter={holdBubble}
|
onMouseEnter={holdBubble}
|
||||||
onMouseLeave={releaseBubble}
|
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={{
|
style={{
|
||||||
background: 'var(--color-surface)',
|
background: 'var(--color-surface)',
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
@@ -158,10 +158,16 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
|
|||||||
}}
|
}}
|
||||||
title="Click to dismiss"
|
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}
|
{bubble.zh}
|
||||||
</p>
|
</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}
|
{bubble.en}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,8 +180,9 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
|
|||||||
aria-label="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: 144,
|
// Size scales with the viewport — see --petal-companion-size in index.css.
|
||||||
height: 144,
|
width: 'var(--petal-companion-size)',
|
||||||
|
height: 'var(--petal-companion-size)',
|
||||||
padding: 0,
|
padding: 0,
|
||||||
borderRadius: 'var(--radius-pill)',
|
borderRadius: 'var(--radius-pill)',
|
||||||
background: 'var(--color-surface-alt)',
|
background: 'var(--color-surface-alt)',
|
||||||
@@ -189,8 +196,13 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
|
|||||||
<LottiePlayer
|
<LottiePlayer
|
||||||
key={companion.id}
|
key={companion.id}
|
||||||
animationData={animationData}
|
animationData={animationData}
|
||||||
className="h-32 w-32"
|
flip={companion.flip}
|
||||||
fallback={<span style={{ fontSize: 72, lineHeight: 1 }}>{MOOD_EMOJI[renderMood]}</span>}
|
className="petal-companion-art"
|
||||||
|
fallback={
|
||||||
|
<span style={{ fontSize: 'calc(var(--petal-companion-size) * 0.5)', lineHeight: 1 }}>
|
||||||
|
{MOOD_EMOJI[renderMood]}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{napping && (
|
{napping && (
|
||||||
<span className="petal-zzz absolute" aria-hidden style={{ color: 'var(--color-muted)' }}>
|
<span className="petal-zzz absolute" aria-hidden style={{ color: 'var(--color-muted)' }}>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,6 +1,9 @@
|
|||||||
import type { Mood } from './useCompanion'
|
import type { Mood } from './useCompanion'
|
||||||
import sleepingCat from './animations/sleeping-cat.json'
|
import sleepingCat from './animations/sleeping-cat.json'
|
||||||
import happyDog from './animations/happy-dog.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 {
|
export interface Companion {
|
||||||
id: string
|
id: string
|
||||||
@@ -12,6 +15,9 @@ export interface Companion {
|
|||||||
// When true the mascot keeps its calm sway + drifting zzz regardless of mood
|
// 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).
|
// (e.g. a cat that's always asleep but still talks in its sleep).
|
||||||
alwaysAsleep?: boolean
|
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
|
// 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
|
// 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'
|
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 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
|
// 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).
|
// (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 BUBBLE_MS = 14_000 // baseline for a tip / break
|
||||||
const CHEER_MS = 6_000 // a short cheer still needs a beat in two languages
|
const CHEER_MS = 9_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 READ_MS_PER_CHAR = 55 // ~18 chars/sec, generous for a bilingual ESL read
|
||||||
const MAX_BUBBLE_MS = 20_000 // cap so a long quote can't pin the bubble forever
|
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 HOVER_GRACE_MS = 3_000 // lingers this long after she stops hovering
|
||||||
const now = () => Date.now()
|
const now = () => Date.now()
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -319,9 +319,17 @@ button, a, input {
|
|||||||
The cozy corner mascot. Gently bobs while awake, settles and sways slowly
|
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. */
|
while napping; its speech bubble pops in; little zzz drift up when asleep. */
|
||||||
.petal-companion {
|
.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;
|
animation: petal-bob 3.2s ease-in-out infinite;
|
||||||
transition: transform 200ms ease;
|
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 {
|
.petal-companion:hover {
|
||||||
transform: translateY(-2px) scale(1.04);
|
transform: translateY(-2px) scale(1.04);
|
||||||
}
|
}
|
||||||
@@ -347,10 +355,10 @@ button, a, input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.petal-zzz {
|
.petal-zzz {
|
||||||
top: 6px;
|
top: calc(var(--petal-companion-size) * 0.04);
|
||||||
right: 14px;
|
right: calc(var(--petal-companion-size) * 0.1);
|
||||||
font-weight: 800;
|
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;
|
animation: petal-zzz 2.4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@keyframes petal-zzz {
|
@keyframes petal-zzz {
|
||||||
|
|||||||
Reference in New Issue
Block a user