From 795a97485a9e9bf8fc9bac127349c64c1d56fb8f Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Mon, 8 Jun 2026 16:14:56 +0200 Subject: [PATCH] feat: add GitHub star nudges to CLI build view and docs sidebar (#271) * feat: load star count during context builds * docs: document star prompt opt-out * fix: initialize demo context star count * feat(docs-site): add GitHub star count widget to docs sidebar * test: isolate star-prompt build-view tests from ambient CI env --- docs-site/app/docs/layout.tsx | 13 +- docs-site/app/global.css | 141 +++++++++++++ docs-site/app/layout.config.tsx | 9 - docs-site/components/github-stars.tsx | 93 +++++++++ docs-site/content/docs/cli-reference/ktx.mdx | 12 ++ packages/cli/src/context-build-view.ts | 96 ++++++++- packages/cli/src/io/symbols.ts | 2 + packages/cli/src/setup-demo-tour.ts | 1 + packages/cli/src/star-prompt/cache.ts | 50 +++++ packages/cli/src/star-prompt/star-count.ts | 76 +++++++ packages/cli/src/star-prompt/star-line.ts | 42 ++++ packages/cli/test/context-build-view.test.ts | 195 +++++++++++++++++- packages/cli/test/star-prompt/cache.test.ts | 73 +++++++ .../cli/test/star-prompt/star-count.test.ts | 90 ++++++++ .../cli/test/star-prompt/star-line.test.ts | 47 +++++ 15 files changed, 928 insertions(+), 12 deletions(-) create mode 100644 docs-site/components/github-stars.tsx create mode 100644 packages/cli/src/star-prompt/cache.ts create mode 100644 packages/cli/src/star-prompt/star-count.ts create mode 100644 packages/cli/src/star-prompt/star-line.ts create mode 100644 packages/cli/test/star-prompt/cache.test.ts create mode 100644 packages/cli/test/star-prompt/star-count.test.ts create mode 100644 packages/cli/test/star-prompt/star-line.test.ts diff --git a/docs-site/app/docs/layout.tsx b/docs-site/app/docs/layout.tsx index ff7d69a9..5f684ea0 100644 --- a/docs-site/app/docs/layout.tsx +++ b/docs-site/app/docs/layout.tsx @@ -2,10 +2,21 @@ import { source } from "@/lib/source"; import { DocsLayout } from "fumadocs-ui/layouts/docs"; import type { ReactNode } from "react"; import { baseOptions } from "@/app/layout.config"; +import { GitHubStars } from "@/components/github-stars"; export default function Layout({ children }: { children: ReactNode }) { return ( - + + + + ), + }} + > {children} ); diff --git a/docs-site/app/global.css b/docs-site/app/global.css index 929e06b4..d6d9ada6 100644 --- a/docs-site/app/global.css +++ b/docs-site/app/global.css @@ -869,6 +869,147 @@ body::after { 50% { opacity: 0.65; transform: scale(0.9); } } +/* ═══════════════════════════════════════════ + GitHub star widget (navbar) + Split pill: GitHub mark + "Star" │ gold star + count. + ═══════════════════════════════════════════ */ +.ktx-stars { + display: inline-flex; + align-items: stretch; + height: 32px; + border-radius: 999px; + border: 1px solid var(--color-fd-border); + background: color-mix(in oklch, var(--color-fd-card) 72%, transparent); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + font-family: var(--font-display), var(--font-sans), sans-serif; + font-size: 13px; + line-height: 1; + color: var(--color-fd-foreground); + text-decoration: none; + overflow: hidden; + box-shadow: 0 1px 2px rgba(27, 27, 24, 0.04); + transition: + transform 0.3s var(--ktx-ease), + box-shadow 0.3s var(--ktx-ease), + border-color 0.3s ease; + animation: ktx-stars-in 0.5s var(--ktx-ease) both; +} + +@keyframes ktx-stars-in { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +.ktx-stars:hover { + transform: translateY(-1px); + border-color: color-mix(in oklch, var(--color-fd-primary) 45%, var(--color-fd-border)); + box-shadow: + 0 6px 18px -8px rgba(14, 116, 144, 0.28), + 0 1px 2px rgba(27, 27, 24, 0.05); +} + +.ktx-stars:focus-visible { + outline: 2px solid var(--color-fd-ring); + outline-offset: 2px; +} + +.dark .ktx-stars { + background: color-mix(in oklch, var(--color-fd-card) 60%, transparent); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25); +} + +.dark .ktx-stars:hover { + border-color: rgba(34, 211, 238, 0.4); + box-shadow: + 0 6px 18px -8px rgba(34, 211, 238, 0.3), + 0 1px 2px rgba(0, 0, 0, 0.3); +} + +.ktx-stars-seg { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 11px; +} + +.ktx-stars-seg--count { + border-left: 1px solid var(--color-fd-border); + background: color-mix(in oklch, var(--color-fd-primary) 6%, transparent); + transition: background 0.3s var(--ktx-ease); +} + +.ktx-stars:hover .ktx-stars-seg--count { + background: color-mix(in oklch, var(--color-fd-primary) 12%, transparent); +} + +.ktx-stars-gh { + width: 15px; + height: 15px; + opacity: 0.85; +} + +.ktx-stars-text { + font-weight: 500; + letter-spacing: -0.01em; +} + +.ktx-stars-star { + width: 14px; + height: 14px; + fill: #f5b301; + transition: transform 0.3s var(--ktx-ease), filter 0.3s var(--ktx-ease); +} + +.ktx-stars:hover .ktx-stars-star { + transform: scale(1.18) rotate(-8deg); + filter: drop-shadow(0 1px 4px rgba(245, 179, 1, 0.55)); +} + +.ktx-stars-count { + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--color-fd-foreground); +} + +/* Skeleton shown only on the rare cold (uncached) fetch */ +.ktx-stars--skeleton { + animation: none; +} + +.ktx-stars-skeleton-bar { + display: inline-block; + width: 26px; + height: 11px; + border-radius: 4px; + background: linear-gradient( + 90deg, + var(--color-fd-muted) 25%, + color-mix(in oklch, var(--color-fd-muted-foreground) 28%, var(--color-fd-muted)) 50%, + var(--color-fd-muted) 75% + ); + background-size: 200% 100%; + animation: ktx-stars-shimmer 1.4s ease-in-out infinite; +} + +@keyframes ktx-stars-shimmer { + from { background-position: 200% 0; } + to { background-position: -200% 0; } +} + +/* Compact on phones: drop the "Star" word, keep mark + count */ +@media (max-width: 640px) { + .ktx-stars-text { display: none; } + .ktx-stars-seg { padding: 0 9px; } +} + +@media (prefers-reduced-motion: reduce) { + .ktx-stars { animation: none; transition: none; } + .ktx-stars:hover { transform: none; } + .ktx-stars:hover .ktx-stars-star { transform: none; } + .ktx-stars-skeleton-bar { animation: none; } +} + /* Dot grid */ .dot-grid { background-image: radial-gradient( diff --git a/docs-site/app/layout.config.tsx b/docs-site/app/layout.config.tsx index af971ee6..8f06d60a 100644 --- a/docs-site/app/layout.config.tsx +++ b/docs-site/app/layout.config.tsx @@ -1,5 +1,4 @@ import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared"; -import { GitHubIcon } from "@/components/github-icon"; import { Logo } from "@/components/logo"; import { SlackIcon } from "@/components/slack-icon"; @@ -26,14 +25,6 @@ export const baseOptions: BaseLayoutProps = { }, ], }, - { - type: "icon", - label: "GitHub", - icon: , - text: "GitHub", - url: "https://github.com/kaelio/ktx", - external: true, - }, { type: "icon", label: "Join the ktx Slack community", diff --git a/docs-site/components/github-stars.tsx b/docs-site/components/github-stars.tsx new file mode 100644 index 00000000..d3b328a3 --- /dev/null +++ b/docs-site/components/github-stars.tsx @@ -0,0 +1,93 @@ +import { Suspense } from "react"; +import { GitHubIcon } from "@/components/github-icon"; + +const REPO = "kaelio/ktx"; +const REPO_URL = `https://github.com/${REPO}`; +const API_URL = `https://api.github.com/repos/${REPO}`; + +async function fetchStarCount(): Promise { + try { + const res = await fetch(API_URL, { + headers: { Accept: "application/vnd.github+json" }, + // Revalidate hourly. GitHub's unauthenticated REST limit is 60 req/h per + // IP, so a single cached server-side fetch keeps the count fresh while + // never exposing visitors to rate limits or layout shift. + next: { revalidate: 3600 }, + }); + if (!res.ok) return null; + const data = (await res.json()) as { stargazers_count?: unknown }; + return typeof data.stargazers_count === "number" + ? data.stargazers_count + : null; + } catch { + return null; + } +} + +/** Compact, GitHub-style count: 847 → "847", 1234 → "1.2k", 12345 → "12.3k". */ +function formatStars(count: number): string { + if (count < 1000) return count.toLocaleString("en-US"); + const thousands = count / 1000; + const rounded = + thousands >= 100 ? Math.round(thousands) : Math.round(thousands * 10) / 10; + return `${rounded}k`; +} + +function StarGlyph() { + return ( + + ); +} + +async function StarsContent() { + const count = await fetchStarCount(); + const label = + count === null + ? "Star ktx on GitHub" + : `Star ktx on GitHub — ${count.toLocaleString("en-US")} stars`; + + return ( + + + + Star + + {count !== null && ( + + + {formatStars(count)} + + )} + + ); +} + +function StarsSkeleton() { + return ( +