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:
prosolis
2026-06-26 11:08:55 -07:00
parent 9d6698dcc6
commit adc0cdff1c
8 changed files with 91 additions and 16 deletions

View File

@@ -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
/>
)
} }

View File

@@ -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

View File

@@ -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'

View File

@@ -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()

View File

@@ -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 {