From c2e4673dd160fcfb92de67e30dc6f7e18fe5dedc Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Tue, 10 Mar 2026 15:42:29 +0000 Subject: [PATCH] feat: Homebrew badge + animated star counter - Add HomebrewBadge component (lightweight styled Box, not MUI Chip) - 'From the creator of Homebrew' with beer mug icon - Responsive: shorter text on mobile - Accessible: role=status, aria-label, tabIndex, focus-visible outline - Hover: border color transitions to primary - Enhance Stars component with animated counter - Counts from 0 to actual value using requestAnimationFrame - Ease-out cubic easing for smooth deceleration - Respects prefers-reduced-motion (instant display) - Tabular-nums for stable width during animation - aria-live=polite for screen reader updates - Integrate badge into hero section (above 'Run Anything' heading) - Add public/stars.json with current count (9718) Bundle impact: +1.75KB raw, +0.76KB gzip (budget was 6KB) --- public/stars.json | 1 + src/components/HomebrewBadge.tsx | 57 ++++++++++++++ src/components/Stars.tsx | 124 ++++++++++++++++++++++++++----- src/pkgx.sh/Hero.tsx | 2 + 4 files changed, 164 insertions(+), 20 deletions(-) create mode 100644 public/stars.json create mode 100644 src/components/HomebrewBadge.tsx diff --git a/public/stars.json b/public/stars.json new file mode 100644 index 00000000..bc1f4a8e --- /dev/null +++ b/public/stars.json @@ -0,0 +1 @@ +9718 \ No newline at end of file diff --git a/src/components/HomebrewBadge.tsx b/src/components/HomebrewBadge.tsx new file mode 100644 index 00000000..c76155af --- /dev/null +++ b/src/components/HomebrewBadge.tsx @@ -0,0 +1,57 @@ +import { Box, Tooltip, useTheme, useMediaQuery } from "@mui/material"; + +/** + * HomebrewBadge — lightweight badge indicating pkgx's Homebrew heritage. + * Uses a styled Box instead of MUI Chip to minimize bundle impact. + * + * Accessibility: focusable, tooltip, role="status", aria-label. + * Responsive: smaller text on mobile. + */ +export default function HomebrewBadge() { + const theme = useTheme(); + const isxs = useMediaQuery(theme.breakpoints.down("md")); + + return ( + + + + {isxs ? "By Homebrew's creator" : "From the creator of Homebrew"} + + + ); +} diff --git a/src/components/Stars.tsx b/src/components/Stars.tsx index eba55fb2..753c1ca8 100644 --- a/src/components/Stars.tsx +++ b/src/components/Stars.tsx @@ -1,27 +1,111 @@ import { Stack, IconButton, Box, Tooltip, Typography, useTheme, useMediaQuery } from "@mui/material"; import { useAsync } from "react-use"; +import { useState, useEffect, useRef, useCallback } from "react"; import github from "../assets/wordmarks/github.svg"; -export default function Stars({ href, hideCountIfMobile }: { href?: string, hideCountIfMobile?: boolean }) { +/** + * Animated star counter that counts from 0 to the actual value. + * Uses requestAnimationFrame for smooth 60fps animation. + * Respects prefers-reduced-motion: skips animation and shows final value instantly. + */ +function useAnimatedCounter(target: number | undefined, durationMs = 1600): string { + const [display, setDisplay] = useState(""); + const prefersReducedMotion = useRef(false); + + useEffect(() => { + if (typeof window !== "undefined") { + prefersReducedMotion.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + } + }, []); + + const formatNumber = useCallback((n: number): string => { + return n.toLocaleString("en-US"); + }, []); + + useEffect(() => { + if (target === undefined || target === null) { + setDisplay(""); + return; + } + + // Respect reduced motion preference + if (prefersReducedMotion.current) { + setDisplay(formatNumber(target)); + return; + } + + let rafId: number; + let startTime: number | null = null; + const startValue = 0; + + const animate = (timestamp: number) => { + if (startTime === null) startTime = timestamp; + const elapsed = timestamp - startTime; + const progress = Math.min(elapsed / durationMs, 1); + + // Ease-out cubic for satisfying deceleration + const eased = 1 - Math.pow(1 - progress, 3); + const current = Math.round(startValue + (target - startValue) * eased); + + setDisplay(formatNumber(current)); + + if (progress < 1) { + rafId = requestAnimationFrame(animate); + } + }; + + rafId = requestAnimationFrame(animate); + + return () => { + if (rafId) cancelAnimationFrame(rafId); + }; + }, [target, durationMs, formatNumber]); + + return display; +} + +export default function Stars({ href, hideCountIfMobile }: { href?: string; hideCountIfMobile?: boolean }) { const theme = useTheme(); - const isxs = useMediaQuery(theme.breakpoints.down('md')); + const isxs = useMediaQuery(theme.breakpoints.down("md")); - const {value: stars} = useAsync(async () => { - const response = await fetch('/stars.json'); + const { value: stars } = useAsync(async () => { + const response = await fetch("/stars.json"); const data = await response.json(); - return data - }, []) - - const display = hideCountIfMobile && isxs ? 'none' : undefined - - return - - - - - - {stars} - - - -} \ No newline at end of file + // Handle both number and string formats + const raw = typeof data === "object" && data !== null ? (data.total ?? data.stars ?? data) : data; + return typeof raw === "string" ? parseInt(raw.replace(/,/g, ""), 10) : Number(raw); + }, []); + + const animatedStars = useAnimatedCounter(stars); + const shouldHide = hideCountIfMobile && isxs; + + return ( + + + + + {!shouldHide && ( + + + {animatedStars || "\u00A0"} + + + )} + + ); +} diff --git a/src/pkgx.sh/Hero.tsx b/src/pkgx.sh/Hero.tsx index 8c3a0cd8..57c3b163 100644 --- a/src/pkgx.sh/Hero.tsx +++ b/src/pkgx.sh/Hero.tsx @@ -3,6 +3,7 @@ import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import React, { useState } from "react"; import HeroTypography from '../components/HeroTypography' +import HomebrewBadge from '../components/HomebrewBadge' import { useSearchParams } from 'react-router-dom' export default function Hero() { @@ -28,6 +29,7 @@ export default function Hero() { : undefined return + Run Anything