diff --git a/apps/docs/.pi/briefs/shieldcn.md b/apps/docs/.pi/briefs/shieldcn.md
new file mode 100644
index 0000000..1e72640
--- /dev/null
+++ b/apps/docs/.pi/briefs/shieldcn.md
@@ -0,0 +1,523 @@
+# shieldcn
+
+Beautiful README badges as a service. A shields.io alternative with the visual quality of shadcn/ui components.
+
+## What this is
+
+A standalone Next.js app that serves styled SVG badge images for use in GitHub READMEs, npm pages, docs sites, and anywhere that accepts ` ` tags. Own repo, own domain, own brand.
+
+Not a jalco ui subpage. A separate product that happens to share the same design language and links back to jalco ui as the component library behind it.
+
+## Why this should be separate
+
+- Shields.io is a product, not a feature. This competes with it, so it should feel like a product.
+- Own domain means own SEO. "shieldcn.dev" ranks on its own.
+- Own repo means contributors can work on badge coverage without touching the component library.
+- Own deploy means badge uptime is independent of docs site deploys.
+- Marketing surface: every badge image served is a backlink. Every README is an ad.
+- Can grow independently — more providers, a badge builder, an API, a GitHub App — without bloating jalco ui.
+
+## Domain
+
+`shieldcn.dev`
+
+## Scaffolding
+
+### Stack (mirror jalco ui)
+
+| Concern | Tool | Notes |
+|---------|------|-------|
+| Framework | Next.js 16 | Same version as jalco ui |
+| Docs | Fumadocs (fumadocs-core, fumadocs-mdx, @fumadocs/base-ui) | Same MDX-driven docs setup |
+| Styling | Tailwind CSS v4 | CSS-first `@theme` config |
+| Components | shadcn/ui primitives | Only what the landing/docs pages need |
+| Fonts | Geist + Geist Mono | Same as jalco ui |
+| Package manager | pnpm | With workspace if needed later |
+| Linting | ESLint + lint-staged + Husky | Same hooks as jalco ui |
+| Deploy | Vercel | Edge caching for badge responses |
+
+### Init commands
+
+```bash
+# Create repo
+mkdir shieldcn && cd shieldcn
+git init
+
+# Scaffold Next.js
+pnpm create next-app@latest . --typescript --tailwind --eslint --app --src-dir=false --import-alias="@/*"
+
+# Add Fumadocs
+pnpm add fumadocs-core fumadocs-mdx
+pnpm add -D @fumadocs/base-ui
+
+# Add shadcn
+pnpm dlx shadcn@latest init
+
+# Add shared deps
+pnpm add class-variance-authority clsx tailwind-merge
+
+# Add dev tooling
+pnpm add -D husky lint-staged tsx
+```
+
+### Repo structure
+
+```
+shieldcn/
+├── app/
+│ ├── layout.tsx ← root layout (Geist fonts, theme provider, metadata)
+│ ├── page.tsx ← landing page: hero, badge builder, comparison, examples
+│ ├── globals.css ← Tailwind v4 @theme config
+│ ├── [...slug]/
+│ │ └── route.ts ← catch-all badge SVG route handler
+│ └── docs/
+│ ├── layout.tsx ← Fumadocs docs layout
+│ ├── page.tsx ← docs index
+│ └── [...slug]/
+│ └── page.tsx ← MDX docs pages
+├── content/
+│ └── docs/
+│ ├── index.mdx ← getting started
+│ ├── meta.json ← Fumadocs page tree
+│ ├── badges/
+│ │ ├── meta.json
+│ │ ├── npm.mdx ← npm badge docs
+│ │ ├── github-stars.mdx
+│ │ ├── github-release.mdx
+│ │ ├── github-ci.mdx
+│ │ ├── github-license.mdx
+│ │ └── discord.mdx
+│ ├── customization/
+│ │ ├── meta.json
+│ │ ├── themes.mdx
+│ │ └── styles.mdx
+│ └── api-reference.mdx
+├── lib/
+│ ├── badges/
+│ │ ├── render.ts ← SVG rendering engine
+│ │ ├── measure.ts ← text width calculation (char width lookup)
+│ │ ├── themes.ts ← shadcn color palettes → resolved hex values
+│ │ ├── icons.ts ← provider SVG icon path data
+│ │ └── types.ts ← shared types
+│ ├── providers/
+│ │ ├── npm.ts ← npm registry + downloads API
+│ │ ├── github.ts ← GitHub repos, releases, actions, license API
+│ │ └── discord.ts ← Discord widget API
+│ └── utils.ts ← cn(), formatCount(), etc.
+├── components/
+│ ├── badge-builder.tsx ← interactive badge builder (client component)
+│ ├── badge-preview.tsx ← live badge preview with copy button
+│ ├── theme-provider.tsx
+│ └── ui/ ← shadcn primitives (button, input, select, etc.)
+├── source.config.ts ← Fumadocs MDX config
+├── next.config.ts ← withMDX wrapper
+├── package.json
+├── tsconfig.json
+├── .gitignore
+├── .husky/
+│ └── pre-commit
+├── AGENTS.md
+├── README.md ← uses its own badges
+└── LICENSE
+```
+
+### Fumadocs setup
+
+`source.config.ts`:
+```ts
+import { defineDocs, defineConfig } from "fumadocs-mdx/config"
+
+export const docs = defineDocs({
+ dir: "content/docs",
+})
+
+export default defineConfig({
+ mdxOptions: {},
+})
+```
+
+`next.config.ts`:
+```ts
+import type { NextConfig } from "next"
+import { createMDX } from "fumadocs-mdx/next"
+
+const nextConfig: NextConfig = {}
+const withMDX = createMDX()
+export default withMDX(nextConfig)
+```
+
+`app/docs/layout.tsx`:
+```tsx
+import { DocsLayout } from "fumadocs-ui/layouts/docs"
+import type { ReactNode } from "react"
+
+export default function Layout({ children }: { children: ReactNode }) {
+ return {children}
+}
+```
+
+### Tailwind v4 config
+
+`globals.css`:
+```css
+@import "tailwindcss";
+
+@theme {
+ --color-background: oklch(100% 0 0);
+ --color-foreground: oklch(14.5% 0.025 264);
+ --color-muted: oklch(96% 0.01 264);
+ --color-muted-foreground: oklch(46% 0.02 264);
+ --color-border: oklch(91% 0.01 264);
+ /* ... standard shadcn tokens */
+}
+```
+
+## URL format
+
+```
+https://shieldcn.dev/{provider}/{...params}.svg → SVG badge image
+https://shieldcn.dev/{provider}/{...params}.json → raw badge data
+https://shieldcn.dev/{provider}/{...params}/shields.json → shields.io compatible
+```
+
+### v1 badge types
+
+| Badge | URL | Data source |
+|-------|-----|-------------|
+| npm version | `/{package}.svg` | registry.npmjs.org |
+| npm downloads | `/npm/{package}/downloads.svg` | api.npmjs.org |
+| GitHub release | `/github/{owner}/{repo}/release.svg` | GitHub Releases API |
+| GitHub stars | `/github/{owner}/{repo}/stars.svg` | GitHub Repos API |
+| CI status | `/github/{owner}/{repo}/ci.svg` | GitHub Actions API |
+| License | `/github/{owner}/{repo}/license.svg` | GitHub Repos API |
+| Discord online | `/discord/{serverId}.svg` | Discord widget API |
+
+### Query params
+
+| Param | Type | Description |
+|-------|------|-------------|
+| `style` | `default` \| `outline` \| `subtle` \| `flat` | Visual style. Default: `default` |
+| `theme` | `zinc` \| `slate` \| `stone` \| `neutral` \| `blue` \| `green` \| `rose` \| ... | Color theme using shadcn palette names. Default: `zinc` |
+| `color` | hex string (no `#`) | Override badge accent color. Overrides theme. |
+| `labelColor` | hex string (no `#`) | Override label background color. |
+| `label` | string | Override the left-side label text |
+| `logo` | `true` \| `false` | Show/hide the provider icon. Default: `true` |
+
+### Usage in markdown
+
+```md
+
+
+
+
+```
+
+## SVG rendering engine
+
+### Core function
+
+```ts
+interface BadgeConfig {
+ label: string // left side text ("npm", "release", "CI")
+ value: string // right side text ("v19.1.0", "45.2k", "passing")
+ icon?: string // SVG path data for provider icon
+ iconViewBox?: string // viewBox for the icon
+ style: "default" | "outline" | "subtle" | "flat"
+ colors: ResolvedColors // resolved hex values
+ statusColor?: string // override value bg for CI status (green/red/amber)
+}
+
+function renderBadge(config: BadgeConfig): string
+```
+
+Returns a complete SVG string. No React, no Tailwind, no external dependencies.
+
+### Layout math
+
+```
+┌─────────────────────────────────────────┐
+│ [icon] label │ value │
+└─────────────────────────────────────────┘
+ ↑ ↑ ↑
+ iconWidth │ valueWidth
+ + padding │ + padding
+ divider
+```
+
+1. Measure label text width using character lookup table
+2. Measure value text width
+3. Add icon width + padding if icon enabled
+4. Calculate total badge width
+5. Position all elements with x/y coordinates
+
+Badge height: 20px — standard README badge height, aligns with shields.io badges.
+
+### Text measurement
+
+Lookup table approach (same technique as shields.io). Pre-measured character widths for the badge font at 11px. No native dependencies.
+
+```ts
+const CHAR_WIDTHS: Record = {
+ "a": 6.2, "b": 6.8, "c": 5.6, // ...
+ "0": 6.6, "1": 4.4, // ...
+}
+
+function measureText(text: string, fontSize: number): number
+```
+
+### Theme system
+
+Map shadcn palette names to resolved hex values:
+
+```ts
+interface ResolvedColors {
+ labelBg: string
+ labelFg: string
+ valueBg: string
+ valueFg: string
+ border: string
+}
+
+const themes: Record = {
+ zinc: { labelBg: "#27272a", labelFg: "#fafafa", valueBg: "#3f3f46", valueFg: "#fafafa", border: "#52525b" },
+ slate: { labelBg: "#1e293b", labelFg: "#f8fafc", valueBg: "#334155", valueFg: "#f8fafc", border: "#475569" },
+ blue: { labelBg: "#1e3a5f", labelFg: "#dbeafe", valueBg: "#2563eb", valueFg: "#ffffff", border: "#3b82f6" },
+ green: { labelBg: "#14532d", labelFg: "#dcfce7", valueBg: "#16a34a", valueFg: "#ffffff", border: "#22c55e" },
+ rose: { labelBg: "#4c0519", labelFg: "#ffe4e6", valueBg: "#e11d48", valueFg: "#ffffff", border: "#f43f5e" },
+ // ...
+}
+```
+
+Default: `zinc`.
+
+Tailwind CSS tokens can't be used directly in SVG images. SVGs served as ` ` are sandboxed — no external CSS, no CSS variables, no class-based styling. The theme system uses the same vocabulary as shadcn (palette names like `zinc`, `slate`, `blue`) but resolves them to hex values at render time.
+
+### Style variants
+
+- **default** — dark label bg, slightly lighter value bg, subtle border, rounded corners
+- **outline** — transparent bg, visible border, themed text colors
+- **subtle** — fully rounded pill, muted background, no strong contrast between label/value
+- **flat** — shields.io compatible flat style for people who want consistency with existing badges
+
+### Icons
+
+Raw SVG path data for each provider:
+
+```ts
+const icons: Record = {
+ npm: { viewBox: "0 0 256 256", path: "M0 256V0h256..." },
+ github: { viewBox: "0 0 16 16", path: "M8 0C3.58 0..." },
+ discord: { viewBox: "0 -28.5 256 256", path: "M216.856339...", fillRule: "nonzero" },
+}
+```
+
+## Data providers
+
+Port the fetch functions from jalco ui's registry libs. Simplify — each provider returns `{ label, value, statusColor? }`.
+
+```ts
+// lib/providers/npm.ts
+export async function getNpmVersion(pkg: string): Promise
+export async function getNpmDownloads(pkg: string): Promise
+
+// lib/providers/github.ts
+export async function getGitHubStars(owner: string, repo: string): Promise
+export async function getGitHubRelease(owner: string, repo: string): Promise
+export async function getGitHubCI(owner: string, repo: string): Promise
+export async function getGitHubLicense(owner: string, repo: string): Promise
+
+// lib/providers/discord.ts
+export async function getDiscordOnline(serverId: string): Promise
+```
+
+## API
+
+The badge service is the API. Every badge URL is a public endpoint that returns data in the requested format.
+
+### SVG endpoint (default)
+
+```
+GET /npm/react.svg → image/svg+xml
+GET /github/vercel/next.js/stars.svg → image/svg+xml
+```
+
+Returns a rendered SVG badge image. This is what ` ` tags and markdown `![]()` reference.
+
+### JSON endpoint
+
+Append `.json` instead of `.svg` to get raw badge data:
+
+```
+GET /npm/react.json → application/json
+GET /github/vercel/next.js/stars.json → application/json
+```
+
+Returns:
+```json
+{
+ "label": "npm",
+ "value": "v19.1.0",
+ "color": "blue",
+ "link": "https://www.npmjs.com/package/react"
+}
+```
+
+This lets people build their own badge rendering or consume the data for dashboards, scripts, or CI. Same cache headers as SVG.
+
+### Shields.io endpoint compatibility
+
+Serve a shields.io-compatible JSON endpoint so people can use shieldcn as a data source with shields.io rendering if they want:
+
+```
+GET /npm/react/shields.json
+```
+
+Returns:
+```json
+{
+ "schemaVersion": 1,
+ "label": "npm",
+ "message": "v19.1.0",
+ "color": "blue"
+}
+```
+
+This works with shields.io's [endpoint badge](https://shields.io/badges/endpoint-badge):
+```md
+
+```
+
+So people can migrate gradually — use shieldcn data with shields.io rendering first, then switch to shieldcn rendering when ready.
+
+### Route handler
+
+`app/[...slug]/route.ts` — single catch-all:
+
+```ts
+export async function GET(
+ request: Request,
+ { params }: { params: { slug: string[] } }
+) {
+ const segments = params.slug
+ const lastSegment = segments[segments.length - 1]
+
+ // Determine response format from file extension
+ const format = lastSegment.endsWith(".json")
+ ? lastSegment.endsWith("/shields.json") ? "shields" : "json"
+ : "svg"
+
+ // Strip extension from last segment for parsing
+ // Parse remaining segments → provider + badge type + resource params
+ // Parse searchParams → style, theme, color, label, logo
+ // Validate inputs
+ // Fetch data from provider API
+ // Return SVG, JSON, or shields-compatible JSON based on format
+}
+```
+
+## Caching
+
+```ts
+return new Response(svg, {
+ headers: {
+ "Content-Type": "image/svg+xml",
+ "Cache-Control": "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400",
+ },
+})
+```
+
+GitHub's Camo image proxy caches for ~4 hours on top of this. Combined with Vercel edge caching, origin hits will be minimal.
+
+## Error handling
+
+On bad params or failed API calls, return a valid SVG badge showing the error:
+
+```
+┌──────────────────────────┐
+│ error │ package not found │
+└──────────────────────────┘
+```
+
+Never return a broken image. Always return a valid SVG.
+
+## Landing page
+
+Simple, one-page site at `/`:
+- Hero: "Beautiful README badges" + a row of example badges rendered live as ` ` tags
+- Badge builder: provider selector, package/repo input, style/theme dropdowns, live preview, copy-to-clipboard markdown
+- Comparison section: same badge in shields.io vs shieldcn side by side
+- URL reference: quick table of all badge types + params
+- Footer: "Built with jal-co/ui" link
+
+The badge builder is a client component. Everything else is static.
+
+## Docs pages (Fumadocs)
+
+Structured under `/docs`:
+- Getting started (install in your README)
+- Per-badge-type pages (npm, GitHub stars, release, CI, license, Discord)
+- Customization: themes, styles
+- API reference: full URL + param spec
+
+Each badge doc page shows:
+- Live badge preview (as ` ` tags)
+- Markdown snippet to copy
+- All relevant query params with examples
+- Visual comparison of style/theme variants
+
+## Cross-linking with jalco ui
+
+- shieldcn footer: "Badge components for React → jal-co/ui"
+- jalco ui docs: "Use these badges in your README → shieldcn.dev"
+- jalco ui npm-badge, release-badge, ci-badge, discord-badge docs: add a "README badge" section with the shieldcn URL equivalent
+- jalco ui README: use shieldcn badges
+
+## Implementation order
+
+1. Scaffold Next.js app with Fumadocs, Tailwind v4, Geist fonts
+2. `lib/badges/measure.ts` — character width lookup table
+3. `lib/badges/themes.ts` — shadcn color palettes
+4. `lib/badges/icons.ts` — provider SVG paths (copy from jalco ui)
+5. `lib/badges/render.ts` — core SVG renderer
+6. Test renderer with hardcoded data — get the visual quality right before adding API calls
+7. `lib/providers/` — port fetch functions from jalco ui
+8. `app/[...slug]/route.ts` — route handler
+9. Test all 7 badge types with real data in GitHub README
+10. Landing page with hero, comparison, badge builder
+11. Fumadocs docs pages
+12. README using its own badges
+13. Deploy to Vercel, connect domain
+14. Add cross-links from jalco ui
+
+## What NOT to build in v1
+
+- Services beyond the 7 listed
+- Dark mode auto-detection (GitHub doesn't support it in README SVGs)
+- Custom fonts
+- Auth, rate limiting, user accounts
+- GitHub App
+- Separate analytics (use Vercel analytics or umami)
+- GraphQL or complex query API — the URL *is* the API, keep it REST/resource-based
+
+## Success criteria
+
+- Badges render correctly in GitHub READMEs
+- Badges render correctly on npm package pages
+- Visual quality is noticeably better than shields.io
+- All 7 badge types work with live data
+- Response times < 200ms (cached)
+- Badge URLs are intuitive
+- Landing page badge builder copies markdown to clipboard
+- Docs cover every badge type and customization option
+- jalco ui's own README uses shieldcn badges
+
+## Future (v2+)
+
+- More providers: PyPI, Crates.io, Packagist, Docker Hub, Codecov, Bundlephobia
+- Custom/static badges: `/badge/{label}/{value}.svg?color=blue`
+- Sparkline badges: tiny inline SVG charts as badge images
+- GitHub App: auto-generates badge markdown for repos
+- `?mode=dark` for dark-background READMEs
+- Badge gallery page
+- Contributor-friendly provider plugin system
+- OpenAPI spec
diff --git a/apps/docs/.source/browser.ts b/apps/docs/.source/browser.ts
index 12992cd..490e939 100644
--- a/apps/docs/.source/browser.ts
+++ b/apps/docs/.source/browser.ts
@@ -7,6 +7,6 @@ const create = browser();
const browserCollections = {
- docs: create.doc("docs", {"index.mdx": () => import("../content/docs/index.mdx?collection=docs"), "components/activity-graph.mdx": () => import("../content/docs/components/activity-graph.mdx?collection=docs"), "components/ai-copy-button.mdx": () => import("../content/docs/components/ai-copy-button.mdx?collection=docs"), "components/api-ref-table.mdx": () => import("../content/docs/components/api-ref-table.mdx?collection=docs"), "components/balanced-text.mdx": () => import("../content/docs/components/balanced-text.mdx?collection=docs"), "components/chat-bubble.mdx": () => import("../content/docs/components/chat-bubble.mdx?collection=docs"), "components/code-block-command.mdx": () => import("../content/docs/components/code-block-command.mdx?collection=docs"), "components/code-block.mdx": () => import("../content/docs/components/code-block.mdx?collection=docs"), "components/code-line.mdx": () => import("../content/docs/components/code-line.mdx?collection=docs"), "components/color-palette.mdx": () => import("../content/docs/components/color-palette.mdx?collection=docs"), "components/commit-graph.mdx": () => import("../content/docs/components/commit-graph.mdx?collection=docs"), "components/contributor-grid.mdx": () => import("../content/docs/components/contributor-grid.mdx?collection=docs"), "components/cron-schedule.mdx": () => import("../content/docs/components/cron-schedule.mdx?collection=docs"), "components/diff-viewer.mdx": () => import("../content/docs/components/diff-viewer.mdx?collection=docs"), "components/env-table.mdx": () => import("../content/docs/components/env-table.mdx?collection=docs"), "components/file-tree.mdx": () => import("../content/docs/components/file-tree.mdx?collection=docs"), "components/github-button-group.mdx": () => import("../content/docs/components/github-button-group.mdx?collection=docs"), "components/github-stars-button.mdx": () => import("../content/docs/components/github-stars-button.mdx?collection=docs"), "components/json-viewer.mdx": () => import("../content/docs/components/json-viewer.mdx?collection=docs"), "components/kbd.mdx": () => import("../content/docs/components/kbd.mdx?collection=docs"), "components/license-badge.mdx": () => import("../content/docs/components/license-badge.mdx?collection=docs"), "components/log-viewer.mdx": () => import("../content/docs/components/log-viewer.mdx?collection=docs"), "components/logo-cloud.mdx": () => import("../content/docs/components/logo-cloud.mdx?collection=docs"), "components/masonry-grid.mdx": () => import("../content/docs/components/masonry-grid.mdx?collection=docs"), "components/npm-badge.mdx": () => import("../content/docs/components/npm-badge.mdx?collection=docs"), "components/pretext.mdx": () => import("../content/docs/components/pretext.mdx?collection=docs"), "components/producthunt-button.mdx": () => import("../content/docs/components/producthunt-button.mdx?collection=docs"), "components/repo-card.mdx": () => import("../content/docs/components/repo-card.mdx?collection=docs"), "components/request-viewer.mdx": () => import("../content/docs/components/request-viewer.mdx?collection=docs"), "components/status-indicator.mdx": () => import("../content/docs/components/status-indicator.mdx?collection=docs"), "components/stepper.mdx": () => import("../content/docs/components/stepper.mdx?collection=docs"), "components/testimonial.mdx": () => import("../content/docs/components/testimonial.mdx?collection=docs"), "components/tip-jar.mdx": () => import("../content/docs/components/tip-jar.mdx?collection=docs"), }),
+ docs: create.doc("docs", {"index.mdx": () => import("../content/docs/index.mdx?collection=docs"), "components/activity-graph.mdx": () => import("../content/docs/components/activity-graph.mdx?collection=docs"), "components/ai-copy-button.mdx": () => import("../content/docs/components/ai-copy-button.mdx?collection=docs"), "components/api-ref-table.mdx": () => import("../content/docs/components/api-ref-table.mdx?collection=docs"), "components/balanced-text.mdx": () => import("../content/docs/components/balanced-text.mdx?collection=docs"), "components/chat-bubble.mdx": () => import("../content/docs/components/chat-bubble.mdx?collection=docs"), "components/ci-badge.mdx": () => import("../content/docs/components/ci-badge.mdx?collection=docs"), "components/code-block-command.mdx": () => import("../content/docs/components/code-block-command.mdx?collection=docs"), "components/code-block.mdx": () => import("../content/docs/components/code-block.mdx?collection=docs"), "components/code-line.mdx": () => import("../content/docs/components/code-line.mdx?collection=docs"), "components/color-palette.mdx": () => import("../content/docs/components/color-palette.mdx?collection=docs"), "components/commit-graph.mdx": () => import("../content/docs/components/commit-graph.mdx?collection=docs"), "components/contributor-grid.mdx": () => import("../content/docs/components/contributor-grid.mdx?collection=docs"), "components/cron-schedule.mdx": () => import("../content/docs/components/cron-schedule.mdx?collection=docs"), "components/diff-viewer.mdx": () => import("../content/docs/components/diff-viewer.mdx?collection=docs"), "components/discord-badge.mdx": () => import("../content/docs/components/discord-badge.mdx?collection=docs"), "components/download-sparkline.mdx": () => import("../content/docs/components/download-sparkline.mdx?collection=docs"), "components/env-table.mdx": () => import("../content/docs/components/env-table.mdx?collection=docs"), "components/file-tree.mdx": () => import("../content/docs/components/file-tree.mdx?collection=docs"), "components/github-button-group.mdx": () => import("../content/docs/components/github-button-group.mdx?collection=docs"), "components/github-stars-button.mdx": () => import("../content/docs/components/github-stars-button.mdx?collection=docs"), "components/json-viewer.mdx": () => import("../content/docs/components/json-viewer.mdx?collection=docs"), "components/kbd.mdx": () => import("../content/docs/components/kbd.mdx?collection=docs"), "components/license-badge.mdx": () => import("../content/docs/components/license-badge.mdx?collection=docs"), "components/log-viewer.mdx": () => import("../content/docs/components/log-viewer.mdx?collection=docs"), "components/logo-cloud.mdx": () => import("../content/docs/components/logo-cloud.mdx?collection=docs"), "components/masonry-grid.mdx": () => import("../content/docs/components/masonry-grid.mdx?collection=docs"), "components/npm-badge.mdx": () => import("../content/docs/components/npm-badge.mdx?collection=docs"), "components/pretext.mdx": () => import("../content/docs/components/pretext.mdx?collection=docs"), "components/producthunt-button.mdx": () => import("../content/docs/components/producthunt-button.mdx?collection=docs"), "components/release-badge.mdx": () => import("../content/docs/components/release-badge.mdx?collection=docs"), "components/repo-card.mdx": () => import("../content/docs/components/repo-card.mdx?collection=docs"), "components/request-viewer.mdx": () => import("../content/docs/components/request-viewer.mdx?collection=docs"), "components/status-indicator.mdx": () => import("../content/docs/components/status-indicator.mdx?collection=docs"), "components/stepper.mdx": () => import("../content/docs/components/stepper.mdx?collection=docs"), "components/testimonial.mdx": () => import("../content/docs/components/testimonial.mdx?collection=docs"), "components/tip-jar.mdx": () => import("../content/docs/components/tip-jar.mdx?collection=docs"), }),
};
export default browserCollections;
\ No newline at end of file
diff --git a/apps/docs/.source/server.ts b/apps/docs/.source/server.ts
index 2fc7446..f414dc1 100644
--- a/apps/docs/.source/server.ts
+++ b/apps/docs/.source/server.ts
@@ -1,31 +1,35 @@
// @ts-nocheck
-import * as __fd_glob_34 from "../content/docs/components/tip-jar.mdx?collection=docs"
-import * as __fd_glob_33 from "../content/docs/components/testimonial.mdx?collection=docs"
-import * as __fd_glob_32 from "../content/docs/components/stepper.mdx?collection=docs"
-import * as __fd_glob_31 from "../content/docs/components/status-indicator.mdx?collection=docs"
-import * as __fd_glob_30 from "../content/docs/components/request-viewer.mdx?collection=docs"
-import * as __fd_glob_29 from "../content/docs/components/repo-card.mdx?collection=docs"
-import * as __fd_glob_28 from "../content/docs/components/producthunt-button.mdx?collection=docs"
-import * as __fd_glob_27 from "../content/docs/components/pretext.mdx?collection=docs"
-import * as __fd_glob_26 from "../content/docs/components/npm-badge.mdx?collection=docs"
-import * as __fd_glob_25 from "../content/docs/components/masonry-grid.mdx?collection=docs"
-import * as __fd_glob_24 from "../content/docs/components/logo-cloud.mdx?collection=docs"
-import * as __fd_glob_23 from "../content/docs/components/log-viewer.mdx?collection=docs"
-import * as __fd_glob_22 from "../content/docs/components/license-badge.mdx?collection=docs"
-import * as __fd_glob_21 from "../content/docs/components/kbd.mdx?collection=docs"
-import * as __fd_glob_20 from "../content/docs/components/json-viewer.mdx?collection=docs"
-import * as __fd_glob_19 from "../content/docs/components/github-stars-button.mdx?collection=docs"
-import * as __fd_glob_18 from "../content/docs/components/github-button-group.mdx?collection=docs"
-import * as __fd_glob_17 from "../content/docs/components/file-tree.mdx?collection=docs"
-import * as __fd_glob_16 from "../content/docs/components/env-table.mdx?collection=docs"
-import * as __fd_glob_15 from "../content/docs/components/diff-viewer.mdx?collection=docs"
-import * as __fd_glob_14 from "../content/docs/components/cron-schedule.mdx?collection=docs"
-import * as __fd_glob_13 from "../content/docs/components/contributor-grid.mdx?collection=docs"
-import * as __fd_glob_12 from "../content/docs/components/commit-graph.mdx?collection=docs"
-import * as __fd_glob_11 from "../content/docs/components/color-palette.mdx?collection=docs"
-import * as __fd_glob_10 from "../content/docs/components/code-line.mdx?collection=docs"
-import * as __fd_glob_9 from "../content/docs/components/code-block.mdx?collection=docs"
-import * as __fd_glob_8 from "../content/docs/components/code-block-command.mdx?collection=docs"
+import * as __fd_glob_38 from "../content/docs/components/tip-jar.mdx?collection=docs"
+import * as __fd_glob_37 from "../content/docs/components/testimonial.mdx?collection=docs"
+import * as __fd_glob_36 from "../content/docs/components/stepper.mdx?collection=docs"
+import * as __fd_glob_35 from "../content/docs/components/status-indicator.mdx?collection=docs"
+import * as __fd_glob_34 from "../content/docs/components/request-viewer.mdx?collection=docs"
+import * as __fd_glob_33 from "../content/docs/components/repo-card.mdx?collection=docs"
+import * as __fd_glob_32 from "../content/docs/components/release-badge.mdx?collection=docs"
+import * as __fd_glob_31 from "../content/docs/components/producthunt-button.mdx?collection=docs"
+import * as __fd_glob_30 from "../content/docs/components/pretext.mdx?collection=docs"
+import * as __fd_glob_29 from "../content/docs/components/npm-badge.mdx?collection=docs"
+import * as __fd_glob_28 from "../content/docs/components/masonry-grid.mdx?collection=docs"
+import * as __fd_glob_27 from "../content/docs/components/logo-cloud.mdx?collection=docs"
+import * as __fd_glob_26 from "../content/docs/components/log-viewer.mdx?collection=docs"
+import * as __fd_glob_25 from "../content/docs/components/license-badge.mdx?collection=docs"
+import * as __fd_glob_24 from "../content/docs/components/kbd.mdx?collection=docs"
+import * as __fd_glob_23 from "../content/docs/components/json-viewer.mdx?collection=docs"
+import * as __fd_glob_22 from "../content/docs/components/github-stars-button.mdx?collection=docs"
+import * as __fd_glob_21 from "../content/docs/components/github-button-group.mdx?collection=docs"
+import * as __fd_glob_20 from "../content/docs/components/file-tree.mdx?collection=docs"
+import * as __fd_glob_19 from "../content/docs/components/env-table.mdx?collection=docs"
+import * as __fd_glob_18 from "../content/docs/components/download-sparkline.mdx?collection=docs"
+import * as __fd_glob_17 from "../content/docs/components/discord-badge.mdx?collection=docs"
+import * as __fd_glob_16 from "../content/docs/components/diff-viewer.mdx?collection=docs"
+import * as __fd_glob_15 from "../content/docs/components/cron-schedule.mdx?collection=docs"
+import * as __fd_glob_14 from "../content/docs/components/contributor-grid.mdx?collection=docs"
+import * as __fd_glob_13 from "../content/docs/components/commit-graph.mdx?collection=docs"
+import * as __fd_glob_12 from "../content/docs/components/color-palette.mdx?collection=docs"
+import * as __fd_glob_11 from "../content/docs/components/code-line.mdx?collection=docs"
+import * as __fd_glob_10 from "../content/docs/components/code-block.mdx?collection=docs"
+import * as __fd_glob_9 from "../content/docs/components/code-block-command.mdx?collection=docs"
+import * as __fd_glob_8 from "../content/docs/components/ci-badge.mdx?collection=docs"
import * as __fd_glob_7 from "../content/docs/components/chat-bubble.mdx?collection=docs"
import * as __fd_glob_6 from "../content/docs/components/balanced-text.mdx?collection=docs"
import * as __fd_glob_5 from "../content/docs/components/api-ref-table.mdx?collection=docs"
@@ -42,4 +46,4 @@ const create = server({"doc":{"passthroughs":["extractedReferences"]}});
-export const docs = await create.docs("docs", "content/docs", {"meta.json": __fd_glob_0, "components/meta.json": __fd_glob_1, }, {"index.mdx": __fd_glob_2, "components/activity-graph.mdx": __fd_glob_3, "components/ai-copy-button.mdx": __fd_glob_4, "components/api-ref-table.mdx": __fd_glob_5, "components/balanced-text.mdx": __fd_glob_6, "components/chat-bubble.mdx": __fd_glob_7, "components/code-block-command.mdx": __fd_glob_8, "components/code-block.mdx": __fd_glob_9, "components/code-line.mdx": __fd_glob_10, "components/color-palette.mdx": __fd_glob_11, "components/commit-graph.mdx": __fd_glob_12, "components/contributor-grid.mdx": __fd_glob_13, "components/cron-schedule.mdx": __fd_glob_14, "components/diff-viewer.mdx": __fd_glob_15, "components/env-table.mdx": __fd_glob_16, "components/file-tree.mdx": __fd_glob_17, "components/github-button-group.mdx": __fd_glob_18, "components/github-stars-button.mdx": __fd_glob_19, "components/json-viewer.mdx": __fd_glob_20, "components/kbd.mdx": __fd_glob_21, "components/license-badge.mdx": __fd_glob_22, "components/log-viewer.mdx": __fd_glob_23, "components/logo-cloud.mdx": __fd_glob_24, "components/masonry-grid.mdx": __fd_glob_25, "components/npm-badge.mdx": __fd_glob_26, "components/pretext.mdx": __fd_glob_27, "components/producthunt-button.mdx": __fd_glob_28, "components/repo-card.mdx": __fd_glob_29, "components/request-viewer.mdx": __fd_glob_30, "components/status-indicator.mdx": __fd_glob_31, "components/stepper.mdx": __fd_glob_32, "components/testimonial.mdx": __fd_glob_33, "components/tip-jar.mdx": __fd_glob_34, });
\ No newline at end of file
+export const docs = await create.docs("docs", "content/docs", {"meta.json": __fd_glob_0, "components/meta.json": __fd_glob_1, }, {"index.mdx": __fd_glob_2, "components/activity-graph.mdx": __fd_glob_3, "components/ai-copy-button.mdx": __fd_glob_4, "components/api-ref-table.mdx": __fd_glob_5, "components/balanced-text.mdx": __fd_glob_6, "components/chat-bubble.mdx": __fd_glob_7, "components/ci-badge.mdx": __fd_glob_8, "components/code-block-command.mdx": __fd_glob_9, "components/code-block.mdx": __fd_glob_10, "components/code-line.mdx": __fd_glob_11, "components/color-palette.mdx": __fd_glob_12, "components/commit-graph.mdx": __fd_glob_13, "components/contributor-grid.mdx": __fd_glob_14, "components/cron-schedule.mdx": __fd_glob_15, "components/diff-viewer.mdx": __fd_glob_16, "components/discord-badge.mdx": __fd_glob_17, "components/download-sparkline.mdx": __fd_glob_18, "components/env-table.mdx": __fd_glob_19, "components/file-tree.mdx": __fd_glob_20, "components/github-button-group.mdx": __fd_glob_21, "components/github-stars-button.mdx": __fd_glob_22, "components/json-viewer.mdx": __fd_glob_23, "components/kbd.mdx": __fd_glob_24, "components/license-badge.mdx": __fd_glob_25, "components/log-viewer.mdx": __fd_glob_26, "components/logo-cloud.mdx": __fd_glob_27, "components/masonry-grid.mdx": __fd_glob_28, "components/npm-badge.mdx": __fd_glob_29, "components/pretext.mdx": __fd_glob_30, "components/producthunt-button.mdx": __fd_glob_31, "components/release-badge.mdx": __fd_glob_32, "components/repo-card.mdx": __fd_glob_33, "components/request-viewer.mdx": __fd_glob_34, "components/status-indicator.mdx": __fd_glob_35, "components/stepper.mdx": __fd_glob_36, "components/testimonial.mdx": __fd_glob_37, "components/tip-jar.mdx": __fd_glob_38, });
\ No newline at end of file
diff --git a/apps/docs/components/docs/footer.tsx b/apps/docs/components/docs/footer.tsx
index 4750f1b..850d717 100644
--- a/apps/docs/components/docs/footer.tsx
+++ b/apps/docs/components/docs/footer.tsx
@@ -1,13 +1,7 @@
import Link from "next/link"
import { JalcoLogo } from "@/components/icons/jalco-logo"
+import { DiscordBadge } from "@/registry/discord-badge/discord-badge"
-function DiscordIcon(props: React.SVGProps) {
- return (
-
-
-
- )
-}
function GitHubIcon(props: React.SVGProps) {
return (
@@ -100,15 +94,12 @@ export function SiteFooter() {
+ )
+}
diff --git a/apps/docs/components/docs/previews/discord-badge.tsx b/apps/docs/components/docs/previews/discord-badge.tsx
new file mode 100644
index 0000000..6f0531b
--- /dev/null
+++ b/apps/docs/components/docs/previews/discord-badge.tsx
@@ -0,0 +1,29 @@
+import { DiscordBadge } from "@/registry/discord-badge/discord-badge"
+import type { DiscordServerData } from "@/registry/discord-badge/lib/discord"
+
+const sampleServer: DiscordServerData = {
+ id: "123456789",
+ name: "Tailwind CSS",
+ instantInvite: "https://discord.gg/tailwindcss",
+ onlineCount: 3_200,
+ memberCount: 12_450,
+}
+
+export default async function Preview() {
+ return (
+
+ )
+}
diff --git a/apps/docs/components/docs/previews/download-sparkline.tsx b/apps/docs/components/docs/previews/download-sparkline.tsx
new file mode 100644
index 0000000..bd4a182
--- /dev/null
+++ b/apps/docs/components/docs/previews/download-sparkline.tsx
@@ -0,0 +1,32 @@
+import { DownloadSparkline } from "@/registry/download-sparkline/download-sparkline"
+import type { NpmDownloadPoint } from "@/registry/download-sparkline/lib/npm"
+
+function generateSampleData(days: number, base: number, trend: number): NpmDownloadPoint[] {
+ const points: NpmDownloadPoint[] = []
+ const now = Date.now()
+ for (let i = 0; i < days; i++) {
+ const date = new Date(now - (days - 1 - i) * 86_400_000)
+ const noise = Math.sin(i * 0.8) * base * 0.15 + (Math.random() - 0.5) * base * 0.1
+ points.push({
+ day: date.toISOString().slice(0, 10),
+ downloads: Math.max(0, Math.round(base + i * trend + noise)),
+ })
+ }
+ return points
+}
+
+const trendingUp = generateSampleData(30, 45_000, 600)
+const steady = generateSampleData(30, 120_000, 0)
+const weekly = generateSampleData(7, 8_000, 200)
+
+export default async function Preview() {
+ return (
+
+ )
+}
diff --git a/apps/docs/components/docs/previews/release-badge.tsx b/apps/docs/components/docs/previews/release-badge.tsx
new file mode 100644
index 0000000..bee477c
--- /dev/null
+++ b/apps/docs/components/docs/previews/release-badge.tsx
@@ -0,0 +1,41 @@
+import { ReleaseBadge } from "@/registry/release-badge/release-badge"
+import type { GitHubReleaseData } from "@/registry/release-badge/lib/github"
+
+const sampleRelease: GitHubReleaseData = {
+ tag: "v15.3.1",
+ name: "Next.js 15.3.1",
+ preRelease: false,
+ draft: false,
+ publishedAt: new Date(Date.now() - 3 * 86_400_000).toISOString(),
+ url: "https://github.com/vercel/next.js/releases/latest",
+ body: null,
+ assetCount: 0,
+}
+
+const preRelease: GitHubReleaseData = {
+ ...sampleRelease,
+ tag: "v16.0.0-canary.1",
+ name: "Next.js 16 Canary",
+ preRelease: true,
+}
+
+export default async function Preview() {
+ return (
+
+ )
+}
diff --git a/apps/docs/content/docs/components/ci-badge.mdx b/apps/docs/content/docs/components/ci-badge.mdx
new file mode 100644
index 0000000..192e744
--- /dev/null
+++ b/apps/docs/content/docs/components/ci-badge.mdx
@@ -0,0 +1,159 @@
+---
+title: "CI Badge"
+description: "GitHub Actions CI status badge showing passing, failing, pending, or cancelled state."
+---
+
+import { CIBadge } from "@/registry/ci-badge/ci-badge"
+
+
+
+## Installation
+
+
+
+## Usage
+
+
+
+ `} />
+
+Async server component — fetches the latest workflow run from the GitHub Actions API at build time, cached 10 minutes via ISR.
+
+## Layouts
+
+
+
+
Inline
+
+ Compact pill with status dot and label. The default layout.
+
+
`,
+ preview:
,
+ },
+ {
+ label: "Failing",
+ code: `
`,
+ preview:
,
+ },
+ {
+ label: "Pending",
+ code: `
`,
+ preview:
,
+ },
+ ]}
+ />
+
+
+
+
With details
+
+ Show workflow name, branch, and duration alongside the status.
+
+
`,
+ preview:
,
+ },
+ ]}
+ />
+
+
+
+
Card
+
+ Feature card showing workflow name, status, branch, duration, and run time.
+
+
`,
+ preview:
,
+ },
+ ]}
+ />
+
+
+
+## API Reference
+
+
+
+## Notes
+
+- **ISR caching.** CI status data is cached for 10 minutes via `next.revalidate`. Set `GITHUB_TOKEN` to raise the rate limit from 60 → 5,000 req/hr.
+- **Pre-fetched data.** Pass the `data` prop to skip the API call — useful for static builds or when fetching data separately.
+- **Status colors.** Passing = green, failing = red, pending = amber (animated pulse), cancelled/skipped = gray.
diff --git a/apps/docs/content/docs/components/discord-badge.mdx b/apps/docs/content/docs/components/discord-badge.mdx
new file mode 100644
index 0000000..391cb2c
--- /dev/null
+++ b/apps/docs/content/docs/components/discord-badge.mdx
@@ -0,0 +1,129 @@
+---
+title: "Discord Badge"
+description: "Live Discord server badge showing server name and online count with the Discord icon."
+---
+
+import { DiscordBadge } from "@/registry/discord-badge/discord-badge"
+
+
+
+## Installation
+
+
+
+## Usage
+
+
+
+ `} />
+
+Async server component — fetches server info from the Discord widget API at build time, cached 1 hour via ISR.
+
+## Layouts
+
+
+
+
Inline
+
+ Compact pill showing the Discord icon and server name. The default layout.
+
+
`,
+ preview:
,
+ },
+ {
+ label: "Discord variant",
+ code: `
`,
+ preview:
,
+ },
+ {
+ label: "With online",
+ code: `
`,
+ preview:
,
+ },
+ ]}
+ />
+
+
+
+
Card
+
+ Feature card showing server name and online count.
+
+
`,
+ preview:
,
+ },
+ ]}
+ />
+
+
+
+## API Reference
+
+
+
+## Notes
+
+- **Widget required.** The target Discord server must have the widget enabled in Server Settings → Widget. If the widget is disabled, the badge returns `null`.
+- **Online count only.** The public widget API only exposes online/presence count, not total members. Total member count requires a bot token — pass it manually via `data.memberCount` if you have it from another source.
+- **ISR caching.** Server data is cached for 1 hour via `next.revalidate`. No API key or bot token required.
+- **Pre-fetched data.** Pass the `data` prop to skip the API call — useful for static builds or when fetching data separately.
diff --git a/apps/docs/content/docs/components/download-sparkline.mdx b/apps/docs/content/docs/components/download-sparkline.mdx
new file mode 100644
index 0000000..1737f03
--- /dev/null
+++ b/apps/docs/content/docs/components/download-sparkline.mdx
@@ -0,0 +1,237 @@
+---
+title: "Download Sparkline"
+description: "Tiny inline SVG sparkline showing npm download trends over time."
+---
+
+import { DownloadSparkline } from "@/registry/download-sparkline/download-sparkline"
+
+
+
+## Installation
+
+
+
+## Usage
+
+
+
+ `} />
+
+Async server component — fetches daily download data from the npm API at build time, cached 1 hour via ISR. Pure SVG, zero charting dependencies.
+
+## Variants
+
+
+
+
Chart types
+
+ Three chart types: line (default), area, and bar.
+
+
`,
+ preview:
,
+ },
+ {
+ label: "Area",
+ code: `
`,
+ preview:
,
+ },
+ {
+ label: "Bar",
+ code: `
`,
+ preview:
,
+ },
+ ]}
+ />
+
+
+
+
Time ranges
+
+ Three time ranges: last week, last month (default), and last year.
+
+
`,
+ preview:
,
+ },
+ {
+ label: "Last month",
+ code: `
`,
+ preview:
,
+ },
+ {
+ label: "Last year",
+ code: `
`,
+ preview:
,
+ },
+ ]}
+ />
+
+
+
+## Examples
+
+
+
+
With trend indicator
+
+ Shows percentage change comparing the first half vs second half of the time range, with a colored arrow.
+
+
`,
+ preview:
,
+ },
+ {
+ label: "Area with trend",
+ code: `
`,
+ preview:
,
+ },
+ ]}
+ />
+
+
+
+
With baseline and date range
+
+ Baseline shows a faint dashed line at the average. Date range labels the time period below the chart.
+
+
`,
+ preview:
,
+ },
+ {
+ label: "Everything",
+ code: `
`,
+ preview:
,
+ },
+ ]}
+ />
+
+
+
+
Custom sizing
+ `,
+ preview: ,
+ },
+ {
+ label: "Compact",
+ code: ` `,
+ preview: ,
+ },
+ ]}
+ />
+
+
+
+## API Reference
+
+
+
+## Notes
+
+- **ISR caching.** Download data is cached for 1 hour via `next.revalidate`. No API key required.
+- **Pure SVG.** Zero charting library dependencies. The sparkline is rendered as a single inline SVG element.
+- **Trend calculation.** The trend percentage compares the average daily downloads in the first half of the range to the second half. Green arrow = increasing, red arrow = decreasing.
+- **Pre-fetched data.** Pass the `data` prop to skip the API call — useful for static builds or when fetching data separately.
+- **Scoped packages.** Supports scoped names like `@tanstack/react-query`.
diff --git a/apps/docs/content/docs/components/meta.json b/apps/docs/content/docs/components/meta.json
index b69c808..8caf317 100644
--- a/apps/docs/content/docs/components/meta.json
+++ b/apps/docs/content/docs/components/meta.json
@@ -13,16 +13,21 @@
"file-tree",
"kbd",
"stepper",
- "---Open Source---",
+ "---GitHub---",
"activity-graph",
+ "ci-badge",
"commit-graph",
"contributor-grid",
"github-button-group",
"github-stars-button",
+ "release-badge",
+ "repo-card",
+ "---Ecosystem---",
+ "discord-badge",
+ "download-sparkline",
"license-badge",
"npm-badge",
"producthunt-button",
- "repo-card",
"---Dev Tools---",
"cron-schedule",
"env-table",
diff --git a/apps/docs/content/docs/components/release-badge.mdx b/apps/docs/content/docs/components/release-badge.mdx
new file mode 100644
index 0000000..6efc01b
--- /dev/null
+++ b/apps/docs/content/docs/components/release-badge.mdx
@@ -0,0 +1,154 @@
+---
+title: "Release Badge"
+description: "Live GitHub release badge showing latest tag, publish date, and pre-release indicator."
+---
+
+import { ReleaseBadge } from "@/registry/release-badge/release-badge"
+
+
+
+## Installation
+
+
+
+## Usage
+
+
+
+ `} />
+
+Async server component — fetches the latest GitHub release at build time, cached 1 hour via ISR.
+
+## Layouts
+
+
+
+
Inline
+
+ Compact pill showing the tag icon and version. The default layout.
+
+
`,
+ preview:
,
+ },
+ {
+ label: "With date",
+ code: `
`,
+ preview:
,
+ },
+ {
+ label: "Outline",
+ code: `
`,
+ preview:
,
+ },
+ ]}
+ />
+
+
+
+
Row
+
+ Segmented horizontal strip showing tag, release name, and publish date.
+
+
`,
+ preview:
,
+ },
+ ]}
+ />
+
+
+
+
Card
+
+ Feature card showing release tag, name, publish date, and asset count.
+
+
`,
+ preview:
,
+ },
+ ]}
+ />
+
+
+
+## API Reference
+
+
+
+## Notes
+
+- **ISR caching.** Release data is cached for 1 hour via `next.revalidate`. Set `GITHUB_TOKEN` to raise the rate limit from 60 → 5,000 req/hr.
+- **Pre-fetched data.** Pass the `data` prop to skip the API call — useful for static builds or when fetching data separately.
+- **Pre-release indicator.** When the latest release is marked as a pre-release on GitHub, an amber "pre" badge appears automatically.
diff --git a/apps/docs/lib/docs.ts b/apps/docs/lib/docs.ts
index 1ac1722..a41548f 100644
--- a/apps/docs/lib/docs.ts
+++ b/apps/docs/lib/docs.ts
@@ -64,25 +64,34 @@ export const docsNav: NavGroup[] = [
],
},
{
- title: "Open Source",
+ title: "GitHub",
items: [
{ title: "Activity Graph", href: "/docs/components/activity-graph", dateAdded: "2026-03-11" },
- {
- title: "GitHub Stars Button",
- href: "/docs/components/github-stars-button",
- dateAdded: "2026-03-10",
- },
+ { title: "CI Badge", href: "/docs/components/ci-badge", dateAdded: "2026-04-22" },
+ { title: "Commit Graph", href: "/docs/components/commit-graph", dateAdded: "2026-03-29" },
+ { title: "Contributor Grid", href: "/docs/components/contributor-grid", dateAdded: "2026-03-29" },
{
title: "GitHub Button Group",
href: "/docs/components/github-button-group",
dateAdded: "2026-03-11",
},
+ {
+ title: "GitHub Stars Button",
+ href: "/docs/components/github-stars-button",
+ dateAdded: "2026-03-10",
+ },
+ { title: "Release Badge", href: "/docs/components/release-badge", dateAdded: "2026-04-22" },
+ { title: "Repo Card", href: "/docs/components/repo-card", dateAdded: "2026-03-29" },
+ ],
+ },
+ {
+ title: "Ecosystem",
+ items: [
+ { title: "Discord Badge", href: "/docs/components/discord-badge", dateAdded: "2026-04-22" },
+ { title: "Download Sparkline", href: "/docs/components/download-sparkline", dateAdded: "2026-04-22" },
+ { title: "License Badge", href: "/docs/components/license-badge", dateAdded: "2026-03-29" },
{ title: "npm Badge", href: "/docs/components/npm-badge", dateAdded: "2026-03-12" },
{ title: "Product Hunt", href: "/docs/components/producthunt-button", dateAdded: "2026-03-12" },
- { title: "Contributor Grid", href: "/docs/components/contributor-grid", dateAdded: "2026-03-29" },
- { title: "Commit Graph", href: "/docs/components/commit-graph", dateAdded: "2026-03-29" },
- { title: "License Badge", href: "/docs/components/license-badge", dateAdded: "2026-03-29" },
- { title: "Repo Card", href: "/docs/components/repo-card", dateAdded: "2026-03-29" },
],
},
{
diff --git a/apps/docs/lib/releases.ts b/apps/docs/lib/releases.ts
index 3ff6827..4b7f733 100644
--- a/apps/docs/lib/releases.ts
+++ b/apps/docs/lib/releases.ts
@@ -32,6 +32,50 @@ export interface Release {
}
export const releases: Release[] = [
+ {
+ version: "2026.04.1",
+ date: "2026-04-22",
+ title: "Open Source Batch",
+ summary:
+ "Leaning harder into the open-source lane. Four new components that round out the GitHub and ecosystem coverage: a release badge that pulls your latest tag, a CI status badge for GitHub Actions, a Discord server widget, and a zero-dep SVG sparkline that shows npm download trends inline. Also split the sidebar into GitHub and Ecosystem sections because 13 items in one list was getting unwieldy.",
+ components: [
+ {
+ name: "release-badge",
+ title: "Release Badge",
+ description:
+ "Live GitHub release badge showing latest tag, publish date, and pre-release indicator. Three layouts.",
+ category: "GitHub",
+ },
+ {
+ name: "ci-badge",
+ title: "CI Badge",
+ description:
+ "GitHub Actions CI status badge with colored status dots for passing, failing, pending, and cancelled states.",
+ category: "GitHub",
+ },
+ {
+ name: "discord-badge",
+ title: "Discord Badge",
+ description:
+ "Live Discord server badge showing server name and online count via the public widget API. Includes a blurple variant.",
+ category: "Ecosystem",
+ },
+ {
+ name: "download-sparkline",
+ title: "Download Sparkline",
+ description:
+ "Tiny inline SVG sparkline showing npm download trends. Three chart types, trend indicator, average baseline, and date range labels. Zero charting dependencies.",
+ category: "Ecosystem",
+ },
+ ],
+ improvements: [
+ {
+ title: "Sidebar reorganization",
+ description:
+ "Split the Open Source section into GitHub (repo-specific components) and Ecosystem (npm, Discord, licensing, platforms) for easier navigation.",
+ },
+ ],
+ },
{
version: "2026.04.0",
date: "2026-04-07",
diff --git a/apps/docs/public/previews/ci-badge-dark.png b/apps/docs/public/previews/ci-badge-dark.png
new file mode 100644
index 0000000..e074329
Binary files /dev/null and b/apps/docs/public/previews/ci-badge-dark.png differ
diff --git a/apps/docs/public/previews/ci-badge-light.png b/apps/docs/public/previews/ci-badge-light.png
new file mode 100644
index 0000000..56be2d7
Binary files /dev/null and b/apps/docs/public/previews/ci-badge-light.png differ
diff --git a/apps/docs/public/previews/discord-badge-dark.png b/apps/docs/public/previews/discord-badge-dark.png
new file mode 100644
index 0000000..508730d
Binary files /dev/null and b/apps/docs/public/previews/discord-badge-dark.png differ
diff --git a/apps/docs/public/previews/discord-badge-light.png b/apps/docs/public/previews/discord-badge-light.png
new file mode 100644
index 0000000..c2a29c5
Binary files /dev/null and b/apps/docs/public/previews/discord-badge-light.png differ
diff --git a/apps/docs/public/previews/download-sparkline-dark.png b/apps/docs/public/previews/download-sparkline-dark.png
new file mode 100644
index 0000000..657ac3a
Binary files /dev/null and b/apps/docs/public/previews/download-sparkline-dark.png differ
diff --git a/apps/docs/public/previews/download-sparkline-light.png b/apps/docs/public/previews/download-sparkline-light.png
new file mode 100644
index 0000000..4a609f3
Binary files /dev/null and b/apps/docs/public/previews/download-sparkline-light.png differ
diff --git a/apps/docs/public/previews/release-badge-dark.png b/apps/docs/public/previews/release-badge-dark.png
new file mode 100644
index 0000000..2ddd6ae
Binary files /dev/null and b/apps/docs/public/previews/release-badge-dark.png differ
diff --git a/apps/docs/public/previews/release-badge-light.png b/apps/docs/public/previews/release-badge-light.png
new file mode 100644
index 0000000..42e743c
Binary files /dev/null and b/apps/docs/public/previews/release-badge-light.png differ
diff --git a/apps/docs/registry.json b/apps/docs/registry.json
index dad6d97..dcaaf9c 100644
--- a/apps/docs/registry.json
+++ b/apps/docs/registry.json
@@ -682,6 +682,92 @@
"type": "registry:component"
}
]
+ },
+ {
+ "name": "release-badge",
+ "type": "registry:component",
+ "title": "Release Badge",
+ "description": "Live GitHub release badge showing latest tag, publish date, and pre-release indicator. Three layouts: inline pill, horizontal row, and expanded card. Async server component — fetches data at build time with ISR.",
+ "dependencies": [
+ "class-variance-authority"
+ ],
+ "categories": [
+ "github",
+ "open-source"
+ ],
+ "files": [
+ {
+ "path": "registry/release-badge/release-badge.tsx",
+ "type": "registry:component"
+ },
+ {
+ "path": "registry/release-badge/lib/github.ts",
+ "type": "registry:lib"
+ }
+ ]
+ },
+ {
+ "name": "discord-badge",
+ "type": "registry:component",
+ "title": "Discord Badge",
+ "description": "Live Discord server badge showing server name and online count with the Discord icon. Two layouts: inline pill and expanded card. Async server component — fetches data at build time with ISR via the public widget API.",
+ "dependencies": [
+ "class-variance-authority"
+ ],
+ "categories": [
+ "community",
+ "open-source"
+ ],
+ "files": [
+ {
+ "path": "registry/discord-badge/discord-badge.tsx",
+ "type": "registry:component"
+ },
+ {
+ "path": "registry/discord-badge/lib/discord.ts",
+ "type": "registry:lib"
+ }
+ ]
+ },
+ {
+ "name": "download-sparkline",
+ "type": "registry:component",
+ "title": "Download Sparkline",
+ "description": "Tiny inline SVG sparkline showing npm download trends over time. Three chart types, trend indicator, date range, and average baseline. Pure SVG, zero charting dependencies. Async server component — fetches data at build time with ISR.",
+ "categories": [
+ "open-source",
+ "code"
+ ],
+ "files": [
+ {
+ "path": "registry/download-sparkline/download-sparkline.tsx",
+ "type": "registry:component"
+ },
+ {
+ "path": "registry/download-sparkline/lib/npm.ts",
+ "type": "registry:lib"
+ }
+ ]
+ },
+ {
+ "name": "ci-badge",
+ "type": "registry:component",
+ "title": "CI Badge",
+ "description": "GitHub Actions CI status badge showing passing, failing, pending, or cancelled state with colored status dot and optional duration. Two layouts: inline pill and expanded card. Async server component — fetches data at build time with ISR.",
+ "categories": [
+ "github",
+ "open-source"
+ ],
+ "files": [
+ {
+ "path": "registry/ci-badge/ci-badge.tsx",
+ "type": "registry:component"
+ },
+ {
+ "path": "registry/ci-badge/lib/github.ts",
+ "type": "registry:lib"
+ }
+ ]
}
]
}
diff --git a/apps/docs/registry/ci-badge/ci-badge.tsx b/apps/docs/registry/ci-badge/ci-badge.tsx
new file mode 100644
index 0000000..da77bee
--- /dev/null
+++ b/apps/docs/registry/ci-badge/ci-badge.tsx
@@ -0,0 +1,397 @@
+/**
+ * jalco-ui
+ * CIBadge
+ * by Justin Levine
+ * ui.justinlevine.me
+ *
+ * GitHub Actions CI status badge showing passing, failing, pending, or cancelled
+ * state with colored status dot and optional duration. Two layouts: inline pill
+ * and expanded card. Async server component — fetches data at build time with ISR.
+ *
+ * Props:
+ * - owner: GitHub username or organization
+ * - repo: GitHub repository name
+ * - workflow?: workflow filename (e.g. "ci.yml") or workflow ID
+ * - branch?: branch to filter by
+ * - layout?: "inline" | "card" (default "inline")
+ * - size?: badge size (inline only)
+ * - showDuration?: show run duration
+ * - showBranch?: show branch name
+ * - showWorkflow?: show workflow name
+ * - data?: pre-fetched CIStatusData to skip the API call
+ *
+ * Notes:
+ * - Async server component — no client JS required
+ * - Fetches api.github.com at build time, cached 10 minutes via ISR
+ * - Optional GITHUB_TOKEN env var raises rate limit to 5,000 req/hr
+ */
+
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { cn } from "@/lib/utils"
+import {
+ fetchCIStatus,
+ formatDuration,
+ formatRelativeDate,
+ type CIStatus,
+ type CIStatusData,
+} from "@/registry/ci-badge/lib/github"
+
+const statusConfig: Record<
+ CIStatus,
+ { label: string; dotClass: string; textClass: string }
+> = {
+ success: {
+ label: "passing",
+ dotClass: "bg-emerald-500",
+ textClass: "text-emerald-700 dark:text-emerald-400",
+ },
+ failure: {
+ label: "failing",
+ dotClass: "bg-red-500",
+ textClass: "text-red-700 dark:text-red-400",
+ },
+ pending: {
+ label: "pending",
+ dotClass: "bg-amber-500 animate-pulse",
+ textClass: "text-amber-700 dark:text-amber-400",
+ },
+ cancelled: {
+ label: "cancelled",
+ dotClass: "bg-zinc-400",
+ textClass: "text-zinc-600 dark:text-zinc-400",
+ },
+ skipped: {
+ label: "skipped",
+ dotClass: "bg-zinc-400",
+ textClass: "text-zinc-600 dark:text-zinc-400",
+ },
+}
+
+function ClockIcon({ className }: { className?: string }) {
+ return (
+
+
+
+
+ )
+}
+
+function GitBranchIcon({ className }: { className?: string }) {
+ return (
+
+
+
+
+
+
+ )
+}
+
+function WorkflowIcon({ className }: { className?: string }) {
+ return (
+
+
+
+ )
+}
+
+const inlineVariants = cva(
+ "inline-flex items-center shrink-0 whitespace-nowrap font-medium transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
+ {
+ variants: {
+ size: {
+ sm: "h-7 gap-1.5 px-2.5 text-xs",
+ default: "h-8 gap-2 px-3 text-sm",
+ lg: "h-9 gap-2.5 px-4 text-sm",
+ },
+ },
+ defaultVariants: {
+ size: "default",
+ },
+ }
+)
+
+type BadgeSize = NonNullable["size"]>
+
+interface CIBadgeBaseProps {
+ /** GitHub username or organization (e.g. "vercel"). */
+ owner: string
+ /** GitHub repository name (e.g. "next.js"). */
+ repo: string
+ /** Workflow filename (e.g. "ci.yml") or workflow ID. Fetches the latest run across all workflows if omitted. */
+ workflow?: string
+ /** Branch to filter by. Uses the repo's default branch if omitted. */
+ branch?: string
+ /** Show run duration. @default true for card, false for inline */
+ showDuration?: boolean
+ /** Show branch name. @default true for card, false for inline */
+ showBranch?: boolean
+ /** Show workflow name. @default false for inline, true for card */
+ showWorkflow?: boolean
+ /** Pre-fetched CI status data. When provided, skips the GitHub API call. */
+ data?: CIStatusData
+}
+
+interface CIBadgeInlineProps
+ extends CIBadgeBaseProps,
+ Omit, "children"> {
+ /** @default "inline" */
+ layout?: "inline"
+ size?: BadgeSize
+}
+
+interface CIBadgeCardProps
+ extends CIBadgeBaseProps,
+ Omit, "children"> {
+ layout: "card"
+ size?: never
+}
+
+type CIBadgeProps = CIBadgeInlineProps | CIBadgeCardProps
+
+function InlineLayout({
+ ci,
+ showDuration,
+ showBranch,
+ showWorkflow,
+ size,
+ className,
+}: {
+ ci: CIStatusData
+ showDuration: boolean
+ showBranch: boolean
+ showWorkflow: boolean
+ size: BadgeSize
+ className?: string
+}) {
+ const config = statusConfig[ci.status]
+
+ return (
+
+
+ {showWorkflow && (
+ {ci.workflowName}
+ )}
+
+ {config.label}
+
+ {showBranch && (
+ <>
+
+
+
+
+ {ci.branch}
+
+
+ >
+ )}
+ {showDuration && ci.durationSeconds != null && (
+ <>
+
+
+
+ {formatDuration(ci.durationSeconds)}
+
+ >
+ )}
+
+ )
+}
+
+function CardLayout({
+ ci,
+ showDuration,
+ showBranch,
+ showWorkflow,
+ className,
+}: {
+ ci: CIStatusData
+ showDuration: boolean
+ showBranch: boolean
+ showWorkflow: boolean
+ className?: string
+}) {
+ const config = statusConfig[ci.status]
+
+ const meta: { icon: React.ReactNode; value: string }[] = []
+
+ if (showBranch) {
+ meta.push({
+ icon: ,
+ value: ci.branch,
+ })
+ }
+
+ if (showDuration && ci.durationSeconds != null) {
+ meta.push({
+ icon: ,
+ value: formatDuration(ci.durationSeconds),
+ })
+ }
+
+ if (ci.startedAt) {
+ meta.push({
+ icon: null,
+ value: formatRelativeDate(ci.startedAt),
+ })
+ }
+
+ return (
+
+
+
+ {showWorkflow && (
+ <>
+
+
+ {ci.workflowName}
+
+ >
+ )}
+
+
+
+ {config.label}
+
+
+
+ {meta.length > 0 && (
+
+ {meta.map((item) => (
+
+ {item.icon}
+ {item.value}
+
+ ))}
+
+ )}
+
+ )
+}
+
+async function CIBadge(props: CIBadgeProps) {
+ const {
+ owner,
+ repo,
+ workflow,
+ branch,
+ layout = "inline",
+ data: dataProp,
+ className,
+ } = props
+
+ const ci = dataProp ?? (await fetchCIStatus(owner, repo, workflow, branch))
+ if (!ci) return null
+
+ if (layout === "card") {
+ const {
+ showDuration = true,
+ showBranch = true,
+ showWorkflow = true,
+ } = props
+ return (
+
+ )
+ }
+
+ const {
+ showDuration = false,
+ showBranch = false,
+ showWorkflow = false,
+ size = "default",
+ } = props as CIBadgeInlineProps
+ return (
+
+ )
+}
+
+export { CIBadge, inlineVariants as ciBadgeInlineVariants }
+export type { CIBadgeProps, CIBadgeInlineProps, CIBadgeCardProps, CIStatusData }
diff --git a/apps/docs/registry/ci-badge/lib/github.ts b/apps/docs/registry/ci-badge/lib/github.ts
new file mode 100644
index 0000000..629711d
--- /dev/null
+++ b/apps/docs/registry/ci-badge/lib/github.ts
@@ -0,0 +1,159 @@
+/**
+ * jalco-ui
+ * lib/github
+ * by Justin Levine
+ * ui.justinlevine.me
+ *
+ * GitHub Actions API client for fetching workflow run status.
+ */
+
+export type CIStatus =
+ | "success"
+ | "failure"
+ | "pending"
+ | "cancelled"
+ | "skipped"
+
+export interface CIStatusData {
+ /** Workflow name (e.g. "CI", "Deploy"). */
+ workflowName: string
+ /** Current status of the latest run. */
+ status: CIStatus
+ /** HTML URL to the workflow run on GitHub. */
+ url: string
+ /** Branch the run was triggered on. */
+ branch: string
+ /** ISO date string of when the run started. */
+ startedAt: string | null
+ /** Duration in seconds (if completed). */
+ durationSeconds: number | null
+}
+
+/**
+ * Fetch the latest workflow run status for a GitHub repository.
+ *
+ * - Uses the public GitHub REST API.
+ * - Optionally authenticates with `process.env.GITHUB_TOKEN` to raise
+ * the rate limit from 60 → 5,000 requests per hour.
+ * - Caches the result for 10 minutes via Next.js ISR.
+ *
+ * @param owner - GitHub username or organization
+ * @param repo - GitHub repository name
+ * @param workflow - Workflow filename (e.g. "ci.yml") or workflow ID
+ * @param branch - Branch to filter runs by (defaults to repo default branch)
+ *
+ * Returns `null` if the request fails or no runs are found.
+ */
+export async function fetchCIStatus(
+ owner: string,
+ repo: string,
+ workflow?: string,
+ branch?: string
+): Promise {
+ try {
+ const params = new URLSearchParams({ per_page: "1" })
+ if (branch) params.set("branch", branch)
+
+ const workflowPath = workflow
+ ? `/workflows/${encodeURIComponent(workflow)}`
+ : ""
+
+ const response = await fetch(
+ `https://api.github.com/repos/${owner}/${repo}/actions${workflowPath}/runs?${params}`,
+ {
+ headers: {
+ Accept: "application/vnd.github.v3+json",
+ ...(process.env.GITHUB_TOKEN
+ ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` }
+ : {}),
+ },
+ next: { revalidate: 600 },
+ }
+ )
+ if (!response.ok) return null
+ const data = await response.json()
+
+ const run = data.workflow_runs?.[0]
+ if (!run) return null
+
+ const status = mapStatus(run.status, run.conclusion)
+
+ let durationSeconds: number | null = null
+ if (run.run_started_at && run.updated_at) {
+ const start = new Date(run.run_started_at).getTime()
+ const end = new Date(run.updated_at).getTime()
+ if (end > start) {
+ durationSeconds = Math.round((end - start) / 1000)
+ }
+ }
+
+ return {
+ workflowName: run.name ?? "CI",
+ status,
+ url: run.html_url,
+ branch: run.head_branch ?? branch ?? "main",
+ startedAt: run.run_started_at ?? null,
+ durationSeconds,
+ }
+ } catch {
+ return null
+ }
+}
+
+function mapStatus(
+ status: string,
+ conclusion: string | null
+): CIStatus {
+ if (status === "completed") {
+ if (conclusion === "success") return "success"
+ if (conclusion === "failure") return "failure"
+ if (conclusion === "cancelled") return "cancelled"
+ if (conclusion === "skipped") return "skipped"
+ return "failure"
+ }
+ return "pending"
+}
+
+/**
+ * Format a duration in seconds into a human-readable label.
+ *
+ * - `45` → "45s"
+ * - `125` → "2m 5s"
+ * - `3661` → "1h 1m"
+ */
+export function formatDuration(seconds: number): string {
+ if (seconds < 60) return `${seconds}s`
+ const mins = Math.floor(seconds / 60)
+ const secs = seconds % 60
+ if (mins < 60) {
+ return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`
+ }
+ const hrs = Math.floor(mins / 60)
+ const remainMins = mins % 60
+ return remainMins > 0 ? `${hrs}h ${remainMins}m` : `${hrs}h`
+}
+
+/**
+ * Format an ISO date into a relative label.
+ */
+export function formatRelativeDate(iso: string): string {
+ const date = new Date(iso)
+ const now = new Date()
+ const diffMs = now.getTime() - date.getTime()
+ const diffDays = Math.floor(diffMs / 86_400_000)
+
+ if (diffDays === 0) {
+ const diffHrs = Math.floor(diffMs / 3_600_000)
+ if (diffHrs === 0) {
+ const diffMins = Math.floor(diffMs / 60_000)
+ return diffMins <= 1 ? "just now" : `${diffMins}m ago`
+ }
+ return `${diffHrs}h ago`
+ }
+ if (diffDays === 1) return "yesterday"
+ if (diffDays < 30) return `${diffDays}d ago`
+ const months = Math.floor(diffDays / 30)
+ if (months < 12) return `${months}mo ago`
+ const years = Math.floor(diffDays / 365)
+ return `${years}y ago`
+}
diff --git a/apps/docs/registry/discord-badge/discord-badge.tsx b/apps/docs/registry/discord-badge/discord-badge.tsx
new file mode 100644
index 0000000..11ef7da
--- /dev/null
+++ b/apps/docs/registry/discord-badge/discord-badge.tsx
@@ -0,0 +1,338 @@
+/**
+ * jalco-ui
+ * DiscordBadge
+ * by Justin Levine
+ * ui.justinlevine.me
+ *
+ * Live Discord server badge showing server name and online count with the Discord icon.
+ * Two layouts: inline pill and expanded card. Async server component —
+ * fetches data at build time with ISR via the public widget API.
+ *
+ * Props:
+ * - serverId: Discord server/guild ID
+ * - layout?: "inline" | "card" (default "inline")
+ * - variant?: visual style variant (inline only)
+ * - size?: badge size (inline only)
+ * - iconStyle?: "currentColor" | "discord" (default "currentColor")
+ * - inviteUrl?: override invite link
+ * - showOnline?: show online member count
+ * - data?: pre-fetched DiscordServerData to skip the API call
+ *
+ * Notes:
+ * - Async server component — no client JS required
+ * - Requires the target server to have the widget enabled
+ * - Fetches discord.com widget API at build time, cached 1 hour via ISR
+ * - No API key or bot token required
+ */
+
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { cn } from "@/lib/utils"
+import {
+ fetchDiscordServer,
+ formatMemberCount,
+ type DiscordServerData,
+} from "@/registry/discord-badge/lib/discord"
+
+function DiscordIcon({
+ iconStyle = "currentColor",
+ className,
+}: {
+ iconStyle?: "currentColor" | "discord"
+ className?: string
+}) {
+ return (
+
+
+
+ )
+}
+
+function UsersIcon({ className }: { className?: string }) {
+ return (
+
+
+
+
+
+
+ )
+}
+
+const inlineVariants = cva(
+ "inline-flex items-center shrink-0 whitespace-nowrap font-medium transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
+ {
+ variants: {
+ variant: {
+ default:
+ "rounded-md border border-border bg-muted/50 text-muted-foreground shadow-xs hover:bg-accent hover:text-accent-foreground",
+ primary:
+ "rounded-md bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
+ secondary:
+ "rounded-md border border-transparent bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
+ outline:
+ "rounded-md border border-border bg-background text-foreground shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+ ghost:
+ "rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ subtle:
+ "rounded-full border border-border/60 bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground",
+ discord:
+ "rounded-md bg-[#5865F2] text-white shadow-xs hover:bg-[#4752C4]",
+ },
+ size: {
+ sm: "h-7 gap-1.5 px-2.5 text-xs [&_svg]:size-3.5",
+ default: "h-8 gap-2 px-3 text-sm [&_svg]:size-4",
+ lg: "h-9 gap-2.5 px-4 text-sm [&_svg]:size-4",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+type InlineVariant = NonNullable["variant"]>
+type BadgeSize = NonNullable["size"]>
+
+interface DiscordBadgeBaseProps {
+ /** Discord server/guild ID. */
+ serverId: string
+ /**
+ * Icon color style:
+ * - `"currentColor"` — inherits text color from the variant (default)
+ * - `"discord"` — Discord blurple (#5865F2)
+ */
+ iconStyle?: "currentColor" | "discord"
+ /** Override the invite link. By default uses the widget's instant invite URL. */
+ inviteUrl?: string
+ /** Show online member count. @default true for card, false for inline */
+ showOnline?: boolean
+ /** Pre-fetched server data. When provided, skips the Discord API call. */
+ data?: DiscordServerData
+}
+
+interface DiscordBadgeInlineProps
+ extends DiscordBadgeBaseProps,
+ Omit, "children"> {
+ /** @default "inline" */
+ layout?: "inline"
+ variant?: InlineVariant
+ size?: BadgeSize
+}
+
+interface DiscordBadgeCardProps
+ extends DiscordBadgeBaseProps,
+ Omit, "children"> {
+ layout: "card"
+ variant?: never
+ size?: never
+}
+
+type DiscordBadgeProps = DiscordBadgeInlineProps | DiscordBadgeCardProps
+
+function InlineLayout({
+ server,
+ iconStyle,
+ showOnline,
+ inviteUrl,
+ variant,
+ size,
+ className,
+}: {
+ server: DiscordServerData
+ iconStyle: "currentColor" | "discord"
+ showOnline: boolean
+ inviteUrl: string | null
+ variant: InlineVariant
+ size: BadgeSize
+ className?: string
+}) {
+ const href = inviteUrl ?? server.instantInvite
+ const Comp = href ? "a" : "span"
+ const linkProps = href
+ ? { href, target: "_blank" as const, rel: "noopener noreferrer" }
+ : {}
+
+ return (
+
+
+ {server.name}
+ {showOnline && (
+ <>
+
+
+
+
+ {formatMemberCount(server.onlineCount)} online
+
+
+ >
+ )}
+
+ )
+}
+
+function CardLayout({
+ server,
+ iconStyle,
+ showOnline,
+ inviteUrl,
+ className,
+}: {
+ server: DiscordServerData
+ iconStyle: "currentColor" | "discord"
+ showOnline: boolean
+ inviteUrl: string | null
+ className?: string
+}) {
+ const href = inviteUrl ?? server.instantInvite
+
+ const content = (
+ <>
+
+
+
+
+ {server.name}
+
+
+ {showOnline && (
+
+
+ {formatMemberCount(server.onlineCount)} online
+
+ )}
+
+
+
+
+
+ {formatMemberCount(server.onlineCount)} online
+
+ {server.memberCount != null && (
+
+
+ {formatMemberCount(server.memberCount)} members
+
+ )}
+
+ >
+ )
+
+ if (href) {
+ return (
+
+ {content}
+
+ )
+ }
+
+ return (
+
+ {content}
+
+ )
+}
+
+async function DiscordBadge(props: DiscordBadgeProps) {
+ const {
+ serverId,
+ layout = "inline",
+ iconStyle = "currentColor",
+ inviteUrl,
+ data: dataProp,
+ className,
+ } = props
+
+ const server = dataProp ?? (await fetchDiscordServer(serverId))
+ if (!server) return null
+
+ const resolvedInvite = inviteUrl ?? null
+
+ if (layout === "card") {
+ const { showOnline = true } = props
+ return (
+
+ )
+ }
+
+ const {
+ showOnline = false,
+ variant = "default",
+ size = "default",
+ } = props as DiscordBadgeInlineProps
+ return (
+
+ )
+}
+
+export { DiscordBadge, inlineVariants as discordBadgeInlineVariants }
+export type {
+ DiscordBadgeProps,
+ DiscordBadgeInlineProps,
+ DiscordBadgeCardProps,
+ DiscordServerData,
+}
diff --git a/apps/docs/registry/discord-badge/lib/discord.ts b/apps/docs/registry/discord-badge/lib/discord.ts
new file mode 100644
index 0000000..255c16c
--- /dev/null
+++ b/apps/docs/registry/discord-badge/lib/discord.ts
@@ -0,0 +1,77 @@
+/**
+ * jalco-ui
+ * lib/discord
+ * by Justin Levine
+ * ui.justinlevine.me
+ *
+ * Discord widget API client for fetching server metadata (online count, name).
+ *
+ * The public widget API only exposes online/presence count, not total member
+ * count. Total members requires a bot token. Pass `memberCount` manually via
+ * the `data` prop if you have it from another source.
+ */
+
+export interface DiscordServerData {
+ /** Server/guild ID. */
+ id: string
+ /** Server name. */
+ name: string
+ /** Instant invite URL (if widget is enabled). */
+ instantInvite: string | null
+ /** Number of members currently online (from widget API). */
+ onlineCount: number
+ /** Total member count. Only available if provided manually — the widget API does not expose this. */
+ memberCount?: number
+}
+
+/**
+ * Fetch public metadata for a Discord server using the widget API.
+ *
+ * - Requires the server to have the widget enabled in Server Settings → Widget.
+ * - No API key or bot token required.
+ * - Caches the result for 1 hour via Next.js ISR (`next.revalidate`).
+ *
+ * Returns `null` if the request fails or the widget is disabled.
+ */
+export async function fetchDiscordServer(
+ serverId: string
+): Promise {
+ try {
+ const response = await fetch(
+ `https://discord.com/api/guilds/${serverId}/widget.json`,
+ { next: { revalidate: 3600 } }
+ )
+ if (!response.ok) return null
+ const data = await response.json()
+
+ if (typeof data.name !== "string") return null
+
+ return {
+ id: data.id,
+ name: data.name,
+ instantInvite: data.instant_invite ?? null,
+ onlineCount: data.presence_count ?? 0,
+ }
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Format a member count for compact display.
+ *
+ * - `236000` → `"236K"`
+ * - `1500` → `"1.5K"`
+ * - `842` → `"842"`
+ */
+export function formatMemberCount(count: number): string {
+ if (count >= 1_000_000) {
+ const value = count / 1_000_000
+ return `${value % 1 === 0 ? value.toFixed(0) : value.toFixed(1)}M`
+ }
+ if (count >= 1_000) {
+ const value = count / 1_000
+ return `${value % 1 === 0 ? value.toFixed(0) : value.toFixed(1)}K`
+ }
+ return count.toLocaleString("en-US")
+}
diff --git a/apps/docs/registry/download-sparkline/download-sparkline.tsx b/apps/docs/registry/download-sparkline/download-sparkline.tsx
new file mode 100644
index 0000000..647c1d8
--- /dev/null
+++ b/apps/docs/registry/download-sparkline/download-sparkline.tsx
@@ -0,0 +1,530 @@
+/**
+ * jalco-ui
+ * DownloadSparkline
+ * by Justin Levine
+ * ui.justinlevine.me
+ *
+ * Tiny inline SVG sparkline showing npm download trends over time. Pure SVG,
+ * zero charting dependencies. Async server component — fetches data at build
+ * time with ISR.
+ *
+ * Props:
+ * - package: npm package name
+ * - range?: "last-week" | "last-month" | "last-year" (default "last-month")
+ * - variant?: "line" | "area" | "bar" (default "line")
+ * - color?: stroke/fill color (default "currentColor")
+ * - width?: SVG width in px (default 120)
+ * - height?: SVG height in px (default 32)
+ * - strokeWidth?: line stroke width (default 1.5)
+ * - showEndpoint?: show a dot at the latest value (default true)
+ * - showLabel?: show formatted total downloads label (default false)
+ * - showTrend?: show percentage change indicator (default false)
+ * - showDateRange?: show date range below the chart (default false)
+ * - showBaseline?: show a faint average reference line (default false)
+ * - data?: pre-fetched NpmDownloadPoint[] to skip the API call
+ *
+ * Notes:
+ * - Async server component — no client JS required
+ * - Fetches api.npmjs.org at build time, cached 1 hour via ISR
+ * - No API key required
+ */
+
+import * as React from "react"
+import { cn } from "@/lib/utils"
+import {
+ fetchNpmDownloads,
+ formatDownloads,
+ type NpmDownloadPoint,
+ type NpmDownloadRange,
+} from "@/registry/download-sparkline/lib/npm"
+
+interface DownloadSparklineProps extends React.ComponentProps<"div"> {
+ /** npm package name (e.g. "react", "@tanstack/react-query"). */
+ package: string
+ /** Time range. @default "last-month" */
+ range?: NpmDownloadRange
+ /** Sparkline chart type. @default "line" */
+ variant?: "line" | "area" | "bar"
+ /** Stroke/fill color. @default "currentColor" */
+ color?: string
+ /** SVG width in pixels. @default 120 */
+ width?: number
+ /** SVG height in pixels. @default 32 */
+ height?: number
+ /** Line stroke width. @default 1.5 */
+ strokeWidth?: number
+ /** Show a dot at the latest data point. @default true */
+ showEndpoint?: boolean
+ /** Show formatted total downloads label. @default false */
+ showLabel?: boolean
+ /** Show percentage change trend indicator (compares first half vs second half). @default false */
+ showTrend?: boolean
+ /** Show date range below the chart (e.g. "Mar 22 – Apr 21"). @default false */
+ showDateRange?: boolean
+ /** Show a faint dashed line at the average value. @default false */
+ showBaseline?: boolean
+ /** Pre-fetched download data. When provided, skips the npm API call. */
+ data?: NpmDownloadPoint[]
+}
+
+function computeStats(points: NpmDownloadPoint[]) {
+ const total = points.reduce((sum, p) => sum + p.downloads, 0)
+ const avg = total / points.length
+
+ const mid = Math.floor(points.length / 2)
+ const firstHalf = points.slice(0, mid)
+ const secondHalf = points.slice(mid)
+ const firstAvg =
+ firstHalf.reduce((s, p) => s + p.downloads, 0) / (firstHalf.length || 1)
+ const secondAvg =
+ secondHalf.reduce((s, p) => s + p.downloads, 0) / (secondHalf.length || 1)
+
+ const trendPct =
+ firstAvg > 0 ? ((secondAvg - firstAvg) / firstAvg) * 100 : 0
+
+ const max = Math.max(...points.map((p) => p.downloads), 1)
+ const min = Math.min(...points.map((p) => p.downloads))
+
+ return { total, avg, max, min, trendPct }
+}
+
+function formatShortDate(iso: string): string {
+ const d = new Date(iso + "T00:00:00")
+ return d.toLocaleDateString("en-US", { month: "short", day: "numeric" })
+}
+
+function buildLinePath(
+ points: NpmDownloadPoint[],
+ width: number,
+ height: number,
+ padding: number
+): string {
+ if (points.length === 0) return ""
+
+ const max = Math.max(...points.map((p) => p.downloads), 1)
+ const min = Math.min(...points.map((p) => p.downloads))
+ const range = max - min || 1
+ const drawHeight = height - padding * 2
+ const drawWidth = width - padding * 2
+
+ return points
+ .map((p, i) => {
+ const x = padding + (i / (points.length - 1)) * drawWidth
+ const y =
+ padding + drawHeight - ((p.downloads - min) / range) * drawHeight
+ return `${i === 0 ? "M" : "L"} ${x.toFixed(2)} ${y.toFixed(2)}`
+ })
+ .join(" ")
+}
+
+function buildAreaPath(
+ points: NpmDownloadPoint[],
+ width: number,
+ height: number,
+ padding: number
+): string {
+ if (points.length === 0) return ""
+
+ const linePath = buildLinePath(points, width, height, padding)
+ const drawWidth = width - padding * 2
+ const lastX = padding + drawWidth
+ const firstX = padding
+ const bottom = height - padding
+
+ return `${linePath} L ${lastX.toFixed(2)} ${bottom.toFixed(2)} L ${firstX.toFixed(2)} ${bottom.toFixed(2)} Z`
+}
+
+function getEndpoint(
+ points: NpmDownloadPoint[],
+ width: number,
+ height: number,
+ padding: number
+): { x: number; y: number } | null {
+ if (points.length === 0) return null
+
+ const max = Math.max(...points.map((p) => p.downloads), 1)
+ const min = Math.min(...points.map((p) => p.downloads))
+ const range = max - min || 1
+ const drawHeight = height - padding * 2
+ const drawWidth = width - padding * 2
+
+ const last = points[points.length - 1]
+ return {
+ x: padding + drawWidth,
+ y:
+ padding +
+ drawHeight -
+ ((last.downloads - min) / range) * drawHeight,
+ }
+}
+
+function getBaselineY(
+ avg: number,
+ points: NpmDownloadPoint[],
+ height: number,
+ padding: number
+): number {
+ const max = Math.max(...points.map((p) => p.downloads), 1)
+ const min = Math.min(...points.map((p) => p.downloads))
+ const range = max - min || 1
+ const drawHeight = height - padding * 2
+ return padding + drawHeight - ((avg - min) / range) * drawHeight
+}
+
+function BaselineLine({
+ y,
+ width,
+ padding,
+ color,
+}: {
+ y: number
+ width: number
+ padding: number
+ color: string
+}) {
+ return (
+
+ )
+}
+
+function LineSparkline({
+ points,
+ width,
+ height,
+ color,
+ strokeWidth,
+ showEndpoint,
+ showBaseline,
+ avg,
+}: {
+ points: NpmDownloadPoint[]
+ width: number
+ height: number
+ color: string
+ strokeWidth: number
+ showEndpoint: boolean
+ showBaseline: boolean
+ avg: number
+}) {
+ const padding = 2 + strokeWidth
+ const path = buildLinePath(points, width, height, padding)
+ const endpoint = showEndpoint
+ ? getEndpoint(points, width, height, padding)
+ : null
+ const baselineY = showBaseline
+ ? getBaselineY(avg, points, height, padding)
+ : null
+
+ return (
+
+ {baselineY != null && (
+
+ )}
+
+ {endpoint && (
+
+ )}
+
+ )
+}
+
+function AreaSparkline({
+ points,
+ width,
+ height,
+ color,
+ strokeWidth,
+ showEndpoint,
+ showBaseline,
+ avg,
+}: {
+ points: NpmDownloadPoint[]
+ width: number
+ height: number
+ color: string
+ strokeWidth: number
+ showEndpoint: boolean
+ showBaseline: boolean
+ avg: number
+}) {
+ const padding = 2 + strokeWidth
+ const linePath = buildLinePath(points, width, height, padding)
+ const areaPath = buildAreaPath(points, width, height, padding)
+ const endpoint = showEndpoint
+ ? getEndpoint(points, width, height, padding)
+ : null
+ const baselineY = showBaseline
+ ? getBaselineY(avg, points, height, padding)
+ : null
+
+ return (
+
+ {baselineY != null && (
+
+ )}
+
+
+ {endpoint && (
+
+ )}
+
+ )
+}
+
+function BarSparkline({
+ points,
+ width,
+ height,
+ color,
+ showBaseline,
+ avg,
+}: {
+ points: NpmDownloadPoint[]
+ width: number
+ height: number
+ color: string
+ showBaseline: boolean
+ avg: number
+}) {
+ if (points.length === 0) return null
+
+ const padding = 2
+ const max = Math.max(...points.map((p) => p.downloads), 1)
+ const min = Math.min(...points.map((p) => p.downloads))
+ const range = max - min || 1
+ const drawHeight = height - padding * 2
+ const drawWidth = width - padding * 2
+ const gap = 1
+ const barWidth = Math.max(
+ 1,
+ (drawWidth - gap * (points.length - 1)) / points.length
+ )
+ const baselineY = showBaseline
+ ? getBaselineY(avg, points, height, padding)
+ : null
+
+ return (
+
+ {baselineY != null && (
+
+ )}
+ {points.map((p, i) => {
+ const barHeight = Math.max(
+ 1,
+ ((p.downloads - min) / range) * drawHeight
+ )
+ const x = padding + i * (barWidth + gap)
+ const y = padding + drawHeight - barHeight
+ return (
+
+ )
+ })}
+
+ )
+}
+
+function TrendIndicator({ trendPct }: { trendPct: number }) {
+ const isUp = trendPct > 0
+ const isFlat = Math.abs(trendPct) < 0.5
+ const formatted = `${isUp ? "+" : ""}${trendPct.toFixed(1)}%`
+
+ if (isFlat) {
+ return (
+
+ {formatted}
+
+ )
+ }
+
+ return (
+
+
+
+
+ {formatted}
+
+ )
+}
+
+async function DownloadSparkline({
+ package: packageName,
+ range = "last-month",
+ variant = "line",
+ color = "currentColor",
+ width = 120,
+ height = 32,
+ strokeWidth = 1.5,
+ showEndpoint = true,
+ showLabel = false,
+ showTrend = false,
+ showDateRange = false,
+ showBaseline = false,
+ data: dataProp,
+ className,
+ ...rest
+}: DownloadSparklineProps) {
+ const points = dataProp ?? (await fetchNpmDownloads(packageName, range))
+ if (points.length === 0) return null
+
+ const stats = computeStats(points)
+
+ const rangeLabel =
+ range === "last-week"
+ ? "7d"
+ : range === "last-year"
+ ? "1y"
+ : "30d"
+
+ const dateStart = points[0].day
+ const dateEnd = points[points.length - 1].day
+
+ const chartProps = { showBaseline, avg: stats.avg }
+
+ return (
+
+
+ {variant === "bar" ? (
+
+ ) : variant === "area" ? (
+
+ ) : (
+
+ )}
+
+ {showLabel && (
+
+ {formatDownloads(stats.total)}
+ /{rangeLabel}
+
+ )}
+ {showTrend && }
+
+
+ {showDateRange && (
+
+ {formatShortDate(dateStart)} – {formatShortDate(dateEnd)}
+
+ )}
+
+ )
+}
+
+export { DownloadSparkline }
+export type { DownloadSparklineProps, NpmDownloadPoint, NpmDownloadRange }
diff --git a/apps/docs/registry/download-sparkline/lib/npm.ts b/apps/docs/registry/download-sparkline/lib/npm.ts
new file mode 100644
index 0000000..5ecfe61
--- /dev/null
+++ b/apps/docs/registry/download-sparkline/lib/npm.ts
@@ -0,0 +1,69 @@
+/**
+ * jalco-ui
+ * lib/npm
+ * by Justin Levine
+ * ui.justinlevine.me
+ *
+ * npm API client for fetching download time-series data for sparkline rendering.
+ */
+
+export interface NpmDownloadPoint {
+ /** ISO date string (YYYY-MM-DD). */
+ day: string
+ /** Download count for this day. */
+ downloads: number
+}
+
+export type NpmDownloadRange = "last-week" | "last-month" | "last-year"
+
+/**
+ * Fetch daily download counts for an npm package over a given range.
+ *
+ * Uses the npm downloads API:
+ * - `api.npmjs.org/downloads/range//`
+ * - No API key required.
+ * - Caches the result for 1 hour via Next.js ISR.
+ *
+ * Returns an empty array if the request fails or the package doesn't exist.
+ */
+export async function fetchNpmDownloads(
+ packageName: string,
+ range: NpmDownloadRange = "last-month"
+): Promise {
+ const encoded = encodeURIComponent(packageName)
+
+ try {
+ const response = await fetch(
+ `https://api.npmjs.org/downloads/range/${range}/${encoded}`,
+ { next: { revalidate: 3600 } }
+ )
+ if (!response.ok) return []
+
+ const data = await response.json()
+ if (!Array.isArray(data.downloads)) return []
+
+ return data.downloads.map(
+ (d: { day: string; downloads: number }) => ({
+ day: d.day,
+ downloads: d.downloads,
+ })
+ )
+ } catch {
+ return []
+ }
+}
+
+/**
+ * Format a download count for compact display.
+ */
+export function formatDownloads(count: number): string {
+ if (count >= 1_000_000) {
+ const value = count / 1_000_000
+ return `${value % 1 === 0 ? value.toFixed(0) : value.toFixed(1)}M`
+ }
+ if (count >= 1_000) {
+ const value = count / 1_000
+ return `${value % 1 === 0 ? value.toFixed(0) : value.toFixed(1)}K`
+ }
+ return count.toLocaleString("en-US")
+}
diff --git a/apps/docs/registry/release-badge/lib/github.ts b/apps/docs/registry/release-badge/lib/github.ts
new file mode 100644
index 0000000..6f38bef
--- /dev/null
+++ b/apps/docs/registry/release-badge/lib/github.ts
@@ -0,0 +1,98 @@
+/**
+ * jalco-ui
+ * lib/github
+ * by Justin Levine
+ * ui.justinlevine.me
+ *
+ * GitHub API client for fetching latest release metadata (tag, date, pre-release, notes).
+ */
+
+export interface GitHubReleaseData {
+ /** Tag name (e.g. "v1.2.0", "2026.04.0"). */
+ tag: string
+ /** Release title (e.g. "Batch 1"). */
+ name: string | null
+ /** Whether this is marked as a pre-release. */
+ preRelease: boolean
+ /** Whether this is a draft release. */
+ draft: boolean
+ /** ISO date string of the release publish date. */
+ publishedAt: string
+ /** HTML URL to the release page on GitHub. */
+ url: string
+ /** Release body / notes (markdown). */
+ body: string | null
+ /** Number of release assets (downloadable files). */
+ assetCount: number
+}
+
+/**
+ * Fetch the latest release for a GitHub repository.
+ *
+ * - Uses the public GitHub REST API — no API key required.
+ * - Optionally authenticates with `process.env.GITHUB_TOKEN` to raise
+ * the rate limit from 60 → 5,000 requests per hour.
+ * - Caches the result for 1 hour via Next.js ISR (`next.revalidate`).
+ *
+ * Returns `null` if the request fails or the repo has no releases.
+ */
+export async function fetchLatestRelease(
+ owner: string,
+ repo: string
+): Promise {
+ try {
+ const response = await fetch(
+ `https://api.github.com/repos/${owner}/${repo}/releases/latest`,
+ {
+ headers: {
+ Accept: "application/vnd.github.v3+json",
+ ...(process.env.GITHUB_TOKEN
+ ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` }
+ : {}),
+ },
+ next: { revalidate: 3600 },
+ }
+ )
+ if (!response.ok) return null
+ const data = await response.json()
+
+ if (typeof data.tag_name !== "string") return null
+
+ return {
+ tag: data.tag_name,
+ name: data.name ?? null,
+ preRelease: data.prerelease === true,
+ draft: data.draft === true,
+ publishedAt: data.published_at,
+ url: data.html_url,
+ body: data.body ?? null,
+ assetCount: Array.isArray(data.assets) ? data.assets.length : 0,
+ }
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Format an ISO date into a relative or short label.
+ *
+ * - Same day → "today"
+ * - Yesterday → "yesterday"
+ * - Within 30 days → "Xd ago"
+ * - Within 12 months → "Xmo ago"
+ * - Older → "Xy ago"
+ */
+export function formatRelativeDate(iso: string): string {
+ const date = new Date(iso)
+ const now = new Date()
+ const diffMs = now.getTime() - date.getTime()
+ const diffDays = Math.floor(diffMs / 86_400_000)
+
+ if (diffDays === 0) return "today"
+ if (diffDays === 1) return "yesterday"
+ if (diffDays < 30) return `${diffDays}d ago`
+ const months = Math.floor(diffDays / 30)
+ if (months < 12) return `${months}mo ago`
+ const years = Math.floor(diffDays / 365)
+ return `${years}y ago`
+}
diff --git a/apps/docs/registry/release-badge/release-badge.tsx b/apps/docs/registry/release-badge/release-badge.tsx
new file mode 100644
index 0000000..39f673c
--- /dev/null
+++ b/apps/docs/registry/release-badge/release-badge.tsx
@@ -0,0 +1,526 @@
+/**
+ * jalco-ui
+ * ReleaseBadge
+ * by Justin Levine
+ * ui.justinlevine.me
+ *
+ * Live GitHub release badge showing latest tag, publish date, and pre-release
+ * indicator. Three layouts: inline pill, horizontal row, and expanded card.
+ * Async server component — fetches data at build time with ISR.
+ *
+ * Props:
+ * - owner: GitHub username or organization
+ * - repo: GitHub repository name
+ * - layout?: "inline" | "row" | "card" (default "inline")
+ * - variant?: visual style variant
+ * - size?: badge size (inline/row only)
+ * - showDate?: show publish date
+ * - showPreRelease?: show pre-release indicator
+ * - showAssets?: show download asset count (card only)
+ * - data?: pre-fetched GitHubReleaseData to skip the API call
+ *
+ * Notes:
+ * - Async server component — no client JS required
+ * - Fetches api.github.com at build time, cached 1 hour via ISR
+ * - Optional GITHUB_TOKEN env var raises rate limit to 5,000 req/hr
+ */
+
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { cn } from "@/lib/utils"
+import {
+ fetchLatestRelease,
+ formatRelativeDate,
+ type GitHubReleaseData,
+} from "@/registry/release-badge/lib/github"
+
+function TagIcon({ className }: { className?: string }) {
+ return (
+
+
+
+ )
+}
+
+function CalendarIcon({ className }: { className?: string }) {
+ return (
+
+
+
+
+ )
+}
+
+function DownloadIcon({ className }: { className?: string }) {
+ return (
+
+
+
+
+ )
+}
+
+const inlineVariants = cva(
+ "inline-flex items-center shrink-0 whitespace-nowrap font-medium transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
+ {
+ variants: {
+ variant: {
+ default:
+ "rounded-md border border-border bg-muted/50 text-muted-foreground shadow-xs hover:bg-accent hover:text-accent-foreground",
+ primary:
+ "rounded-md bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
+ secondary:
+ "rounded-md border border-transparent bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
+ outline:
+ "rounded-md border border-border bg-background text-foreground shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+ ghost:
+ "rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ subtle:
+ "rounded-full border border-border/60 bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground",
+ },
+ size: {
+ sm: "h-7 gap-1.5 px-2.5 text-xs [&_svg]:size-3.5",
+ default: "h-8 gap-2 px-3 text-sm [&_svg]:size-4",
+ lg: "h-9 gap-2.5 px-4 text-sm [&_svg]:size-4",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+const rowVariants = cva(
+ "inline-flex items-center shrink-0 overflow-hidden whitespace-nowrap font-medium",
+ {
+ variants: {
+ variant: {
+ default:
+ "rounded-md border border-border bg-muted/50 text-muted-foreground shadow-xs",
+ secondary:
+ "rounded-md border border-transparent bg-secondary text-secondary-foreground shadow-xs",
+ outline:
+ "rounded-md border border-border bg-background text-foreground shadow-xs dark:bg-input/30 dark:border-input",
+ ghost: "rounded-md text-muted-foreground",
+ subtle:
+ "rounded-full border border-border/60 bg-muted/40 text-muted-foreground",
+ },
+ size: {
+ sm: "h-7 text-xs [&_svg]:size-3 [&>[data-segment]]:gap-1.5 [&>[data-segment]]:px-2.5",
+ default:
+ "h-8 text-sm [&_svg]:size-3.5 [&>[data-segment]]:gap-2 [&>[data-segment]]:px-3",
+ lg: "h-9 text-sm [&_svg]:size-4 [&>[data-segment]]:gap-2 [&>[data-segment]]:px-4",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+type InlineVariant = NonNullable["variant"]>
+type RowVariant = NonNullable["variant"]>
+type BadgeSize = NonNullable["size"]>
+
+interface ReleaseBadgeBaseProps {
+ /** GitHub username or organization (e.g. "vercel"). */
+ owner: string
+ /** GitHub repository name (e.g. "next.js"). */
+ repo: string
+ /** Show publish date. @default false for inline, true for row/card */
+ showDate?: boolean
+ /** Show pre-release indicator. @default true */
+ showPreRelease?: boolean
+ /** Show asset/download count (card only). @default true */
+ showAssets?: boolean
+ /** Pre-fetched release data. When provided, skips the GitHub API call. */
+ data?: GitHubReleaseData
+}
+
+interface ReleaseBadgeInlineProps
+ extends ReleaseBadgeBaseProps,
+ Omit, "children"> {
+ /** @default "inline" */
+ layout?: "inline"
+ variant?: InlineVariant
+ size?: BadgeSize
+}
+
+interface ReleaseBadgeRowProps
+ extends ReleaseBadgeBaseProps,
+ Omit, "children"> {
+ layout: "row"
+ variant?: RowVariant
+ size?: BadgeSize
+}
+
+interface ReleaseBadgeCardProps
+ extends ReleaseBadgeBaseProps,
+ Omit, "children"> {
+ layout: "card"
+ variant?: never
+ size?: never
+}
+
+type ReleaseBadgeProps =
+ | ReleaseBadgeInlineProps
+ | ReleaseBadgeRowProps
+ | ReleaseBadgeCardProps
+
+function Divider({
+ className,
+ variant,
+}: {
+ className?: string
+ variant?: string
+}) {
+ return (
+
+ )
+}
+
+function InlineLayout({
+ release,
+ showDate,
+ showPreRelease,
+ variant,
+ size,
+ className,
+}: {
+ release: GitHubReleaseData
+ showDate: boolean
+ showPreRelease: boolean
+ variant: InlineVariant
+ size: BadgeSize
+ className?: string
+}) {
+ return (
+
+
+ {release.tag}
+ {showPreRelease && release.preRelease && (
+
+ pre
+
+ )}
+ {showDate && (
+ <>
+
+
+ {formatRelativeDate(release.publishedAt)}
+
+ >
+ )}
+
+ )
+}
+
+function RowLayout({
+ release,
+ showDate,
+ showPreRelease,
+ variant,
+ size,
+ className,
+}: {
+ release: GitHubReleaseData
+ showDate: boolean
+ showPreRelease: boolean
+ variant: RowVariant
+ size: BadgeSize
+ className?: string
+}) {
+ const segments: {
+ key: string
+ label: string
+ content: React.ReactNode
+ }[] = [
+ {
+ key: "tag",
+ label: `Release ${release.tag}`,
+ content: (
+ <>
+
+ {release.tag}
+ {showPreRelease && release.preRelease && (
+
+ pre
+
+ )}
+ >
+ ),
+ },
+ ]
+
+ if (release.name) {
+ segments.push({
+ key: "name",
+ label: release.name,
+ content: (
+ {release.name}
+ ),
+ })
+ }
+
+ if (showDate) {
+ segments.push({
+ key: "date",
+ label: `Published ${formatRelativeDate(release.publishedAt)}`,
+ content: (
+ <>
+
+ {formatRelativeDate(release.publishedAt)}
+ >
+ ),
+ })
+ }
+
+ const hoverClass =
+ variant === "default"
+ ? "hover:bg-accent hover:text-accent-foreground"
+ : variant === "secondary"
+ ? "hover:bg-secondary/80"
+ : variant === "outline"
+ ? "hover:bg-accent hover:text-accent-foreground dark:hover:bg-input/50"
+ : variant === "ghost"
+ ? "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50"
+ : "hover:bg-muted hover:text-foreground"
+
+ return (
+
+ )
+}
+
+function CardLayout({
+ release,
+ showDate,
+ showPreRelease,
+ showAssets,
+ className,
+}: {
+ release: GitHubReleaseData
+ showDate: boolean
+ showPreRelease: boolean
+ showAssets: boolean
+ className?: string
+}) {
+ const meta: { icon: React.ReactNode; value: string }[] = []
+
+ if (showDate) {
+ meta.push({
+ icon: ,
+ value: formatRelativeDate(release.publishedAt),
+ })
+ }
+
+ if (showAssets && release.assetCount > 0) {
+ meta.push({
+ icon: ,
+ value: `${release.assetCount} asset${release.assetCount === 1 ? "" : "s"}`,
+ })
+ }
+
+ return (
+
+
+
+
+
+ {release.tag}
+
+ {showPreRelease && release.preRelease && (
+
+ pre-release
+
+ )}
+
+
+
+ {(release.name || meta.length > 0) && (
+
+ {release.name && (
+
+ {release.name}
+
+ )}
+ {meta.length > 0 && (
+
+ {meta.map((item) => (
+
+ {item.icon}
+ {item.value}
+
+ ))}
+
+ )}
+
+ )}
+
+ )
+}
+
+async function ReleaseBadge(props: ReleaseBadgeProps) {
+ const {
+ owner,
+ repo,
+ layout = "inline",
+ data: dataProp,
+ className,
+ } = props
+
+ const release = dataProp ?? (await fetchLatestRelease(owner, repo))
+ if (!release) return null
+
+ if (layout === "card") {
+ const {
+ showDate = true,
+ showPreRelease = true,
+ showAssets = true,
+ } = props
+ return (
+
+ )
+ }
+
+ if (layout === "row") {
+ const rowProps = props as ReleaseBadgeRowProps
+ const {
+ showDate = true,
+ showPreRelease = true,
+ variant = "default",
+ size = "default",
+ } = rowProps
+ return (
+
+ )
+ }
+
+ const {
+ showDate = false,
+ showPreRelease = true,
+ variant = "default",
+ size = "default",
+ } = props as ReleaseBadgeInlineProps
+ return (
+
+ )
+}
+
+export {
+ ReleaseBadge,
+ inlineVariants as releaseBadgeInlineVariants,
+ rowVariants as releaseBadgeRowVariants,
+}
+export type {
+ ReleaseBadgeProps,
+ ReleaseBadgeInlineProps,
+ ReleaseBadgeRowProps,
+ ReleaseBadgeCardProps,
+ GitHubReleaseData,
+}