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 /
// not-yet-revealed) is shown instead of the video.
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:
@ -49,7 +55,7 @@ type RefreshMascotProps = {
// 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
// acceptable.)
export function RefreshMascot({ caption, revealing, videoRef }: RefreshMascotProps) {
export function RefreshMascot({ caption, revealing, videoRef, onPoke }: RefreshMascotProps) {
const native = isNativePlatform();
const reducedMotion = useMemo(
@ -65,7 +71,15 @@ export function RefreshMascot({ caption, revealing, videoRef }: RefreshMascotPro
const showVideo = revealing && !reducedMotion;
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
here they break out of this «formal» card and ripple across the
whole light-blue chrome. This card holds just the mascot + caption. */}

View file

@ -385,6 +385,24 @@ export const stagePulseRing = style({
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),
// `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

View file

@ -231,6 +231,20 @@ export function StreamHeader({
const showPulse =
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
// `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
@ -574,6 +588,7 @@ export function StreamHeader({
caption={t('Direct.refreshing')}
revealing={isRefreshSnap(curtain.snap) || curtain.isDragging}
videoRef={mascotVideoRef}
onPoke={spawnEcho}
/>
)}
</header>
@ -600,6 +615,10 @@ export function StreamHeader({
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>
</div>
)}