feat(stream-header): spawn an echo pulse ring when the user taps the refresh mascot

This commit is contained in:
heaven 2026-06-08 23:13:33 +03:00
parent 47255c6bf1
commit b210f2b3a2
3 changed files with 53 additions and 2 deletions

View file

@ -27,6 +27,12 @@ type RefreshMascotProps = {
// never rises mid-dance. Null while the still poster (reduced-motion / // never rises mid-dance. Null while the still poster (reduced-motion /
// not-yet-revealed) is shown instead of the video. // not-yet-revealed) is shown instead of the video.
videoRef?: React.Ref<HTMLVideoElement>; videoRef?: React.Ref<HTMLVideoElement>;
// Called when the user taps the mascot — StreamHeader spawns a one-shot
// "echo" pulse ring on top of the ambient radar. A clean tap fires this;
// a drag instead bubbles to the curtain body gesture (it moves past the
// dead-zone → `preventDefault` → the browser suppresses the click), so the
// poke never fights the pull gesture.
onPoke?: () => void;
}; };
// The card revealed below the tabs row by the curtain's `refresh` snap: // The card revealed below the tabs row by the curtain's `refresh` snap:
@ -49,7 +55,7 @@ type RefreshMascotProps = {
// of the app reads this query one-shot too, and this card is native-only // of the app reads this query one-shot too, and this card is native-only
// + aria-hidden, so a mid-session OS toggle not re-evaluating here is // + aria-hidden, so a mid-session OS toggle not re-evaluating here is
// acceptable.) // acceptable.)
export function RefreshMascot({ caption, revealing, videoRef }: RefreshMascotProps) { export function RefreshMascot({ caption, revealing, videoRef, onPoke }: RefreshMascotProps) {
const native = isNativePlatform(); const native = isNativePlatform();
const reducedMotion = useMemo( const reducedMotion = useMemo(
@ -65,7 +71,15 @@ export function RefreshMascot({ caption, revealing, videoRef }: RefreshMascotPro
const showVideo = revealing && !reducedMotion; const showVideo = revealing && !reducedMotion;
return ( return (
<div className={css.mascotCard} aria-hidden="true"> <div
className={css.mascotCard}
aria-hidden="true"
// Tap-to-poke easter-egg: spawn an echo ring. The card is decorative +
// aria-hidden (sighted-only delight), so no keyboard/role affordance
// applies — drag-to-refresh stays the real interaction.
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
onClick={onPoke}
>
{/* The radar-pulse rings live at the header level (StreamHeader), not {/* The radar-pulse rings live at the header level (StreamHeader), not
here they break out of this «formal» card and ripple across the here they break out of this «formal» card and ripple across the
whole light-blue chrome. This card holds just the mascot + caption. */} whole light-blue chrome. This card holds just the mascot + caption. */}

View file

@ -385,6 +385,24 @@ export const stagePulseRing = style({
animationIterationCount: 'infinite', animationIterationCount: 'infinite',
}); });
// One-shot "echo" ring spawned when the user taps the mascot — IDENTICAL to an
// ambient ring (same `ringKeyframes`, 4s, same 0.26 peak), just a single pulse
// instead of looping. StreamHeader adds one <circle> per tap to `echoes` and
// drops it from the DOM by a timer once it's faded.
export const stagePulseEcho = style({
fill: 'none',
stroke: 'rgb(248, 80, 160)',
strokeWidth: 2,
transformBox: 'fill-box',
transformOrigin: 'center',
opacity: 0,
animationName: ringKeyframes,
animationDuration: '4s',
animationTimingFunction: 'ease-out',
animationIterationCount: 1,
animationFillMode: 'forwards',
});
// The looping mascot video (or its still poster under reduced-motion), // The looping mascot video (or its still poster under reduced-motion),
// `object-fit: contain` so the whole dance frame fits the box. The box is // `object-fit: contain` so the whole dance frame fits the box. The box is
// SMALLER than the card height (geometry.ts), so it's centred with // SMALLER than the card height (geometry.ts), so it's centred with

View file

@ -231,6 +231,20 @@ export function StreamHeader({
const showPulse = const showPulse =
isNativePlatform() && !reducedMotion && (isRefreshSnap(curtain.snap) || curtain.isDragging); isNativePlatform() && !reducedMotion && (isRefreshSnap(curtain.snap) || curtain.isDragging);
// Tap-to-poke echo rings. Tapping the mascot (RefreshMascot `onPoke`) spawns
// a one-shot ring (`stagePulseEcho`) on top of the ambient radar pulse —
// same look + speed as an ambient ring, just one pass; each is dropped from
// the DOM by a timer once its 4s pulse has faded. They only paint while the
// pulse layer is mounted (`showPulse`).
const [echoes, setEchoes] = useState<number[]>([]);
const echoIdRef = useRef(0);
const spawnEcho = useCallback(() => {
const id = echoIdRef.current;
echoIdRef.current += 1;
setEchoes((prev) => [...prev, id]);
window.setTimeout(() => setEchoes((prev) => prev.filter((x) => x !== id)), 4100);
}, []);
// Pull-to-refresh side-effect. When the curtain commits to the // Pull-to-refresh side-effect. When the curtain commits to the
// `refresh` snap, keep the mascot dancing for at least REFRESH_MIN_MS // `refresh` snap, keep the mascot dancing for at least REFRESH_MIN_MS
// (so a near-instant result doesn't flash), capped at REFRESH_MAX_MS // (so a near-instant result doesn't flash), capped at REFRESH_MAX_MS
@ -574,6 +588,7 @@ export function StreamHeader({
caption={t('Direct.refreshing')} caption={t('Direct.refreshing')}
revealing={isRefreshSnap(curtain.snap) || curtain.isDragging} revealing={isRefreshSnap(curtain.snap) || curtain.isDragging}
videoRef={mascotVideoRef} videoRef={mascotVideoRef}
onPoke={spawnEcho}
/> />
)} )}
</header> </header>
@ -600,6 +615,10 @@ export function StreamHeader({
style={{ animationDelay: `${delay}s` }} style={{ animationDelay: `${delay}s` }}
/> />
))} ))}
{/* One-shot echo rings, one per mascot tap (see `spawnEcho`). */}
{echoes.map((id) => (
<circle key={`echo-${id}`} className={css.stagePulseEcho} cx={380} cy={380} r={380} />
))}
</svg> </svg>
</div> </div>
)} )}