Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions public/stars.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
9718
57 changes: 57 additions & 0 deletions src/components/HomebrewBadge.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Tooltip
title="Max Howell created Homebrew, the package manager for macOS"
arrow
placement="bottom"
enterTouchDelay={0}
>
<Box
component="span"
role="status"
aria-label="pkgx is from the creator of Homebrew"
tabIndex={0}
sx={{
display: "inline-flex",
alignItems: "center",
gap: 0.75,
border: "1px solid rgba(149, 178, 184, 0.3)",
borderRadius: "16px",
px: isxs ? 1.5 : 2,
py: 0.5,
color: "text.secondary",
fontSize: isxs ? 11 : 13,
fontWeight: 400,
letterSpacing: 0.3,
cursor: "default",
transition: "border-color 0.2s ease, color 0.2s ease",
"&:hover": {
borderColor: "primary.main",
color: "text.primary",
},
"&:focus-visible": {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: 2,
},
}}
>
<span role="img" aria-hidden="true" style={{ fontSize: isxs ? 14 : 16, lineHeight: 1 }}>
🍺
</span>
{isxs ? "By Homebrew's creator" : "From the creator of Homebrew"}
</Box>
</Tooltip>
);
}
124 changes: 104 additions & 20 deletions src/components/Stars.tsx
Original file line number Diff line number Diff line change
@@ -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<string>("");
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 <Stack spacing={0} direction='row' alignItems='center'>
<IconButton href={href || 'https://github.com/pkgxdev/pkgx'}>
<Box component='img' src={github}/>
</IconButton>
<Tooltip title='Total Org. Stars' arrow placement='right' enterTouchDelay={0} sx={{display}}>
<Typography color='text.secondary' width={44} fontSize={13} overflow='clip' component='span'>
{stars}
</Typography>
</Tooltip>
</Stack>
}
// 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 (
<Stack spacing={0} direction="row" alignItems="center">
<IconButton
href={href || "https://github.com/pkgxdev/pkgx"}
aria-label="View pkgx on GitHub"
>
<Box component="img" src={github} alt="GitHub" />
</IconButton>
{!shouldHide && (
<Tooltip title="Total Org. Stars" arrow placement="right" enterTouchDelay={0}>
<Typography
color="text.secondary"
fontSize={13}
component="span"
aria-live="polite"
aria-label={stars ? `${stars.toLocaleString("en-US")} GitHub stars` : "Loading stars"}
sx={{
minWidth: 44,
overflow: "clip",
fontVariantNumeric: "tabular-nums",
fontFeatureSettings: '"tnum"',
}}
>
{animatedStars || "\u00A0"}
</Typography>
</Tooltip>
)}
</Stack>
);
}
2 changes: 2 additions & 0 deletions src/pkgx.sh/Hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -28,6 +29,7 @@ export default function Hero() {
: undefined

return <Stack spacing={6} textAlign='center' mx='auto' alignItems='center' sx={isxs ? undefined : {"&&": {mt: 22}}}>
<HomebrewBadge />
<HeroTypography>
Run Anything
</HeroTypography>
Expand Down
Loading