Skip to content

Commit c2e4673

Browse files
author
OpenClaw
committed
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)
1 parent cd58a4c commit c2e4673

4 files changed

Lines changed: 164 additions & 20 deletions

File tree

public/stars.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
9718

src/components/HomebrewBadge.tsx

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { Box, Tooltip, useTheme, useMediaQuery } from "@mui/material";
2+
3+
/**
4+
* HomebrewBadge — lightweight badge indicating pkgx's Homebrew heritage.
5+
* Uses a styled Box instead of MUI Chip to minimize bundle impact.
6+
*
7+
* Accessibility: focusable, tooltip, role="status", aria-label.
8+
* Responsive: smaller text on mobile.
9+
*/
10+
export default function HomebrewBadge() {
11+
const theme = useTheme();
12+
const isxs = useMediaQuery(theme.breakpoints.down("md"));
13+
14+
return (
15+
<Tooltip
16+
title="Max Howell created Homebrew, the package manager for macOS"
17+
arrow
18+
placement="bottom"
19+
enterTouchDelay={0}
20+
>
21+
<Box
22+
component="span"
23+
role="status"
24+
aria-label="pkgx is from the creator of Homebrew"
25+
tabIndex={0}
26+
sx={{
27+
display: "inline-flex",
28+
alignItems: "center",
29+
gap: 0.75,
30+
border: "1px solid rgba(149, 178, 184, 0.3)",
31+
borderRadius: "16px",
32+
px: isxs ? 1.5 : 2,
33+
py: 0.5,
34+
color: "text.secondary",
35+
fontSize: isxs ? 11 : 13,
36+
fontWeight: 400,
37+
letterSpacing: 0.3,
38+
cursor: "default",
39+
transition: "border-color 0.2s ease, color 0.2s ease",
40+
"&:hover": {
41+
borderColor: "primary.main",
42+
color: "text.primary",
43+
},
44+
"&:focus-visible": {
45+
outline: `2px solid ${theme.palette.primary.main}`,
46+
outlineOffset: 2,
47+
},
48+
}}
49+
>
50+
<span role="img" aria-hidden="true" style={{ fontSize: isxs ? 14 : 16, lineHeight: 1 }}>
51+
🍺
52+
</span>
53+
{isxs ? "By Homebrew's creator" : "From the creator of Homebrew"}
54+
</Box>
55+
</Tooltip>
56+
);
57+
}

src/components/Stars.tsx

Lines changed: 104 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,111 @@
11
import { Stack, IconButton, Box, Tooltip, Typography, useTheme, useMediaQuery } from "@mui/material";
22
import { useAsync } from "react-use";
3+
import { useState, useEffect, useRef, useCallback } from "react";
34
import github from "../assets/wordmarks/github.svg";
45

5-
export default function Stars({ href, hideCountIfMobile }: { href?: string, hideCountIfMobile?: boolean }) {
6+
/**
7+
* Animated star counter that counts from 0 to the actual value.
8+
* Uses requestAnimationFrame for smooth 60fps animation.
9+
* Respects prefers-reduced-motion: skips animation and shows final value instantly.
10+
*/
11+
function useAnimatedCounter(target: number | undefined, durationMs = 1600): string {
12+
const [display, setDisplay] = useState<string>("");
13+
const prefersReducedMotion = useRef(false);
14+
15+
useEffect(() => {
16+
if (typeof window !== "undefined") {
17+
prefersReducedMotion.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
18+
}
19+
}, []);
20+
21+
const formatNumber = useCallback((n: number): string => {
22+
return n.toLocaleString("en-US");
23+
}, []);
24+
25+
useEffect(() => {
26+
if (target === undefined || target === null) {
27+
setDisplay("");
28+
return;
29+
}
30+
31+
// Respect reduced motion preference
32+
if (prefersReducedMotion.current) {
33+
setDisplay(formatNumber(target));
34+
return;
35+
}
36+
37+
let rafId: number;
38+
let startTime: number | null = null;
39+
const startValue = 0;
40+
41+
const animate = (timestamp: number) => {
42+
if (startTime === null) startTime = timestamp;
43+
const elapsed = timestamp - startTime;
44+
const progress = Math.min(elapsed / durationMs, 1);
45+
46+
// Ease-out cubic for satisfying deceleration
47+
const eased = 1 - Math.pow(1 - progress, 3);
48+
const current = Math.round(startValue + (target - startValue) * eased);
49+
50+
setDisplay(formatNumber(current));
51+
52+
if (progress < 1) {
53+
rafId = requestAnimationFrame(animate);
54+
}
55+
};
56+
57+
rafId = requestAnimationFrame(animate);
58+
59+
return () => {
60+
if (rafId) cancelAnimationFrame(rafId);
61+
};
62+
}, [target, durationMs, formatNumber]);
63+
64+
return display;
65+
}
66+
67+
export default function Stars({ href, hideCountIfMobile }: { href?: string; hideCountIfMobile?: boolean }) {
668
const theme = useTheme();
7-
const isxs = useMediaQuery(theme.breakpoints.down('md'));
69+
const isxs = useMediaQuery(theme.breakpoints.down("md"));
870

9-
const {value: stars} = useAsync(async () => {
10-
const response = await fetch('/stars.json');
71+
const { value: stars } = useAsync(async () => {
72+
const response = await fetch("/stars.json");
1173
const data = await response.json();
12-
return data
13-
}, [])
14-
15-
const display = hideCountIfMobile && isxs ? 'none' : undefined
16-
17-
return <Stack spacing={0} direction='row' alignItems='center'>
18-
<IconButton href={href || 'https://github.com/pkgxdev/pkgx'}>
19-
<Box component='img' src={github}/>
20-
</IconButton>
21-
<Tooltip title='Total Org. Stars' arrow placement='right' enterTouchDelay={0} sx={{display}}>
22-
<Typography color='text.secondary' width={44} fontSize={13} overflow='clip' component='span'>
23-
{stars}
24-
</Typography>
25-
</Tooltip>
26-
</Stack>
27-
}
74+
// Handle both number and string formats
75+
const raw = typeof data === "object" && data !== null ? (data.total ?? data.stars ?? data) : data;
76+
return typeof raw === "string" ? parseInt(raw.replace(/,/g, ""), 10) : Number(raw);
77+
}, []);
78+
79+
const animatedStars = useAnimatedCounter(stars);
80+
const shouldHide = hideCountIfMobile && isxs;
81+
82+
return (
83+
<Stack spacing={0} direction="row" alignItems="center">
84+
<IconButton
85+
href={href || "https://github.com/pkgxdev/pkgx"}
86+
aria-label="View pkgx on GitHub"
87+
>
88+
<Box component="img" src={github} alt="GitHub" />
89+
</IconButton>
90+
{!shouldHide && (
91+
<Tooltip title="Total Org. Stars" arrow placement="right" enterTouchDelay={0}>
92+
<Typography
93+
color="text.secondary"
94+
fontSize={13}
95+
component="span"
96+
aria-live="polite"
97+
aria-label={stars ? `${stars.toLocaleString("en-US")} GitHub stars` : "Loading stars"}
98+
sx={{
99+
minWidth: 44,
100+
overflow: "clip",
101+
fontVariantNumeric: "tabular-nums",
102+
fontFeatureSettings: '"tnum"',
103+
}}
104+
>
105+
{animatedStars || "\u00A0"}
106+
</Typography>
107+
</Tooltip>
108+
)}
109+
</Stack>
110+
);
111+
}

src/pkgx.sh/Hero.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
33
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
44
import React, { useState } from "react";
55
import HeroTypography from '../components/HeroTypography'
6+
import HomebrewBadge from '../components/HomebrewBadge'
67
import { useSearchParams } from 'react-router-dom'
78

89
export default function Hero() {
@@ -28,6 +29,7 @@ export default function Hero() {
2829
: undefined
2930

3031
return <Stack spacing={6} textAlign='center' mx='auto' alignItems='center' sx={isxs ? undefined : {"&&": {mt: 22}}}>
32+
<HomebrewBadge />
3133
<HeroTypography>
3234
Run Anything
3335
</HeroTypography>

0 commit comments

Comments
 (0)