Grow the companion, fix idle fallback, add update banner
Companion: bump the corner mascot ~12% (128→144) and make animation resolution robust — fall back to the idle clip when a mood has no clip of its own, and pin the always-asleep cat to one pose so its Lottie never reloads. This stops a bare emoji from replacing the cute companion on idle. Update notifications: serve a build id at /api/version (a hash of the embedded dist/index.html, which changes on every frontend deploy). The client records its load-time id, polls every 90s (and on tab focus), and floats a Mandarin-first "new version available" banner offering a refresh when the id changes. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
@@ -44,10 +44,20 @@ export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }:
|
||||
// dozes them, keep their normal idle pose instead of a sleepy face. Only a
|
||||
// mascot with a real sleep animation (the always-asleep cat) shows the zzz.
|
||||
const hasSleepClip = Boolean(companion.animations.sleeping)
|
||||
const renderMood: Mood =
|
||||
mood === 'sleeping' && !companion.alwaysAsleep && !hasSleepClip ? 'idle' : mood
|
||||
// An always-asleep mascot (the cat) stays pinned to one pose so its Lottie
|
||||
// never reloads as the engine flips moods underneath it.
|
||||
const renderMood: Mood = companion.alwaysAsleep
|
||||
? 'idle'
|
||||
: mood === 'sleeping' && !hasSleepClip
|
||||
? 'idle'
|
||||
: mood
|
||||
const napping = companion.alwaysAsleep || (mood === 'sleeping' && hasSleepClip)
|
||||
|
||||
// Never swap the cute companion for a bare emoji on an idle nap: fall back to
|
||||
// the idle animation when a mood has no clip of its own, and only reach for
|
||||
// the emoji if the companion ships no animations at all.
|
||||
const animationData = companion.animations[renderMood] ?? companion.animations.idle
|
||||
|
||||
function choose(id: string) {
|
||||
setCompanionId(id)
|
||||
try {
|
||||
@@ -153,8 +163,8 @@ export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }:
|
||||
aria-label="Choose a companion"
|
||||
className={`petal-companion pointer-events-auto select-none ${napping ? 'petal-companion-sleep' : ''}`}
|
||||
style={{
|
||||
width: 128,
|
||||
height: 128,
|
||||
width: 144,
|
||||
height: 144,
|
||||
padding: 0,
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
background: 'var(--color-surface-alt)',
|
||||
@@ -167,9 +177,9 @@ export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }:
|
||||
>
|
||||
<LottiePlayer
|
||||
key={companion.id}
|
||||
animationData={companion.animations[renderMood]}
|
||||
className="h-28 w-28"
|
||||
fallback={<span style={{ fontSize: 64, lineHeight: 1 }}>{MOOD_EMOJI[renderMood]}</span>}
|
||||
animationData={animationData}
|
||||
className="h-32 w-32"
|
||||
fallback={<span style={{ fontSize: 72, lineHeight: 1 }}>{MOOD_EMOJI[renderMood]}</span>}
|
||||
/>
|
||||
{napping && (
|
||||
<span className="petal-zzz absolute" aria-hidden style={{ color: 'var(--color-muted)' }}>
|
||||
|
||||
64
web/src/components/UpdateBanner/UpdateBanner.tsx
Normal file
64
web/src/components/UpdateBanner/UpdateBanner.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
// UpdateBanner gently floats down from the top when a newer build has been
|
||||
// deployed, inviting a refresh. Mandarin-first copy (north star Note #17), soft
|
||||
// rose styling, and a clear primary action. Dismissable — if she dismisses it,
|
||||
// the next deploy (or reload) will surface a fresh one.
|
||||
export function UpdateBanner() {
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
if (dismissed) return null
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-x-0 top-4 z-50 flex justify-center px-4">
|
||||
<div
|
||||
role="status"
|
||||
className="petal-update pointer-events-auto flex items-center gap-3 py-2.5 pl-4 pr-2.5"
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
fontFamily: 'var(--font-ui)',
|
||||
maxWidth: 'min(92vw, 460px)',
|
||||
}}
|
||||
>
|
||||
<span aria-hidden style={{ fontSize: 20, lineHeight: 1 }}>
|
||||
🌸
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className="truncate text-sm font-bold leading-snug"
|
||||
style={{ color: 'var(--color-plum)' }}
|
||||
>
|
||||
有新版本啦
|
||||
</p>
|
||||
<p className="truncate text-xs leading-snug" style={{ color: 'var(--color-muted)' }}>
|
||||
A new version is ready — refresh to update.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.reload()}
|
||||
className="shrink-0 rounded-full px-3.5 py-1.5 text-sm font-bold text-white transition-colors"
|
||||
style={{ background: 'var(--color-accent)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||
>
|
||||
刷新 · Refresh
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDismissed(true)}
|
||||
aria-label="稍后再说 · Dismiss"
|
||||
title="稍后再说 · Dismiss"
|
||||
className="shrink-0 rounded-full px-1.5 text-lg leading-none transition-colors"
|
||||
style={{ color: 'var(--color-muted)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-plum)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--color-muted)')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user