feat(stream-header): spawn an echo pulse ring when the user taps the refresh mascot
This commit is contained in:
parent
47255c6bf1
commit
b210f2b3a2
3 changed files with 53 additions and 2 deletions
|
|
@ -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. */}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue