diff --git a/app/components/footer.tsx b/app/components/footer.tsx
index b058b6b..9dda829 100644
--- a/app/components/footer.tsx
+++ b/app/components/footer.tsx
@@ -1,8 +1,8 @@
import Link from "next/link";
+import { repoUrl } from "@/lib/github";
import { casePath, headingText, useCases } from "@/lib/use-cases";
import { Logo } from "./logo";
-
-const repo = "https://github.com/david-sling/wave";
+import { StarButton } from "./star-button";
export function Footer() {
return (
@@ -24,6 +24,10 @@ export function Footer() {
.
+ {/* Closing the block that says the project is open source, rather
+ than sitting in the Project column, where it would have been a
+ list item repeating what that sentence already said. */}
+
@@ -62,10 +66,9 @@ export function Footer() {
Project
diff --git a/app/components/nav.tsx b/app/components/nav.tsx
index 9b955c9..ebec53b 100644
--- a/app/components/nav.tsx
+++ b/app/components/nav.tsx
@@ -1,5 +1,6 @@
import { Logo } from "./logo";
import { NavVeil } from "./nav-veil";
+import { StarButton } from "./star-button";
const links = [
{ hash: "#how", label: "How it works" },
@@ -7,8 +8,6 @@ const links = [
{ hash: "#agents", label: "Agents" },
];
-const selfHost = "https://github.com/david-sling/wave";
-
/**
* The primary nav.
*
@@ -17,6 +16,10 @@ const selfHost = "https://github.com/david-sling/wave";
* fetching the page again; anywhere else they carry the path, so they lead
* home. `Create a channel` is always a fragment, because every page that
* shows this nav carries the create form.
+ *
+ * The star chip stands where a `Self-host` link used to: both led to the same
+ * repository, and only one of them asks for anything. It hides with the links
+ * rather than with the pill, so a phone keeps the wordmark and the one action.
*/
export function Nav({ atHome = true }: { atHome?: boolean }) {
const home = atHome ? "" : "/";
@@ -31,13 +34,13 @@ export function Nav({ atHome = true }: { atHome?: boolean }) {
{link.label}
))}
-
- Self-host
-
-
- Create a channel
-
+
);
diff --git a/app/components/star-button.tsx b/app/components/star-button.tsx
new file mode 100644
index 0000000..4033172
--- /dev/null
+++ b/app/components/star-button.tsx
@@ -0,0 +1,70 @@
+import { formatStars, repoUrl, starCount } from "@/lib/github";
+
+/**
+ * GitHub's own mark.
+ *
+ * Referential use, under the same policy as `lib/client-marks.ts`: the glyph
+ * is the official one as published (Simple Icons, CC0 1.0,
+ * https://simpleicons.org), drawn whole and unaltered, and never larger than
+ * Wave's own mark beside it. It lives here rather than in `icons.tsx`,
+ * which is an authored set on one stroke weight and one grid, and rather
+ * than in `client-marks.ts`, which is keyed by the agent clients that can
+ * join a channel. GitHub is neither.
+ */
+function GitHubMark({ size = 16 }: { size?: number }) {
+ return (
+
+ );
+}
+
+/**
+ * The one place the page asks for something other than a channel.
+ *
+ * A chip, not a button: the ink pill is the only primary action on any
+ * surface, and an open-source project asking to be starred must not compete
+ * with the thing the reader came to do. The mark carries the destination, so
+ * the nav takes the short label and the footer, which has the room, spells
+ * it out.
+ *
+ * The count is shown whatever it is, nought included. It is the number of
+ * people who have starred this, not a claim about the project, and the
+ * accessible name says what it counts since "Star 2" does not.
+ */
+export async function StarButton({
+ label = "Star on GitHub",
+ className,
+}: {
+ label?: string;
+ className?: string;
+}) {
+ const stars = await starCount();
+ return (
+
+
+ {label}
+ {stars !== null && (
+
+ {formatStars(stars)}
+
+ )}
+
+ );
+}
diff --git a/lib/github.test.ts b/lib/github.test.ts
new file mode 100644
index 0000000..cdc596f
--- /dev/null
+++ b/lib/github.test.ts
@@ -0,0 +1,72 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { formatStars, starCount } from './github'
+
+/**
+ * The count is decoration, so what matters is that nothing it touches can
+ * take a page down with it, and that an unknown count stays distinguishable
+ * from a real count of nought.
+ */
+describe('formatStars', () => {
+ it('writes a small count whole', () => {
+ expect(formatStars(52)).toBe('52')
+ expect(formatStars(999)).toBe('999')
+ })
+
+ it('writes a thousand as a rounded k, without a trailing .0', () => {
+ expect(formatStars(1000)).toBe('1k')
+ expect(formatStars(1249)).toBe('1.2k')
+ expect(formatStars(9950)).toBe('10k')
+ expect(formatStars(12400)).toBe('12k')
+ })
+})
+
+describe('starCount', () => {
+ const answer = (body: unknown, ok = true) =>
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => ({ ok, json: async () => body }) as unknown as Response),
+ )
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ // `starCount` is memoised per render pass; tests share one.
+ ;(starCount as unknown as { clear?: () => void }).clear?.()
+ })
+
+ it('reports a small count rather than hiding it', async () => {
+ answer({ stargazers_count: 2 })
+ expect(await starCount()).toBe(2)
+ })
+
+ it('tells nought apart from not knowing', async () => {
+ answer({ stargazers_count: 0 })
+ expect(await starCount()).toBe(0)
+ })
+
+ it('returns a count worth reading', async () => {
+ answer({ stargazers_count: 412 })
+ expect(await starCount()).toBe(412)
+ })
+
+ it('returns nothing when GitHub refuses, rather than throwing', async () => {
+ answer({ message: 'API rate limit exceeded' }, false)
+ expect(await starCount()).toBeNull()
+ })
+
+ it('returns nothing when the request fails outright', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => {
+ throw new Error('timed out')
+ }),
+ )
+ expect(await starCount()).toBeNull()
+ })
+
+ it('returns nothing when the field is missing or not a number', async () => {
+ answer({})
+ expect(await starCount()).toBeNull()
+ answer({ stargazers_count: '412' })
+ expect(await starCount()).toBeNull()
+ })
+})
diff --git a/lib/github.ts b/lib/github.ts
new file mode 100644
index 0000000..4b66963
--- /dev/null
+++ b/lib/github.ts
@@ -0,0 +1,60 @@
+/**
+ * The project's own repository, and how many people have starred it.
+ *
+ * The URL lives here because the star control, the nav and the footer all
+ * point at it. The count is a marketing number and nothing depends on it, so
+ * every failure path returns `null` and the control renders as a plain ask:
+ * a landing page must not fail to build because GitHub was slow or had run
+ * out of anonymous requests for this IP.
+ */
+
+import { cache } from "react";
+
+export const repoUrl = "https://github.com/david-sling/wave";
+
+const api = "https://api.github.com/repos/david-sling/wave";
+
+/** How long a count is served before it is fetched again. */
+const REVALIDATE_SECONDS = 3600;
+
+/** Past this the request is abandoned; a stale count beats a slow page. */
+const TIMEOUT_MS = 2500;
+
+/**
+ * The star count, or `null` when GitHub did not answer.
+ *
+ * `null` means unknown, not zero: a count of nought is a real answer and is
+ * shown as one.
+ *
+ * Wrapped in `cache` rather than left to fetch's own memoisation, because the
+ * abort signal opts this request out of that (it matches on the signal), and
+ * the nav and the footer both ask within one render.
+ */
+export const starCount = cache(async (): Promise => {
+ try {
+ const response = await fetch(api, {
+ headers: { accept: "application/vnd.github+json" },
+ signal: AbortSignal.timeout(TIMEOUT_MS),
+ next: { revalidate: REVALIDATE_SECONDS },
+ });
+ if (!response.ok) return null;
+ const body: unknown = await response.json();
+ const stars = (body as { stargazers_count?: unknown }).stargazers_count;
+ if (typeof stars !== "number" || !Number.isFinite(stars)) return null;
+ return Math.max(0, Math.floor(stars));
+ } catch {
+ return null;
+ }
+});
+
+/**
+ * A count at the width a nav chip can hold: 128, 1.2k, 12k.
+ *
+ * Rounded to tenths before the branch, not after: `toFixed` on the raw
+ * quotient reads 9950 as 9.9k, because 9.95 is not 9.95 in binary.
+ */
+export function formatStars(count: number): string {
+ if (count < 1000) return String(count);
+ const tenths = Math.round(count / 100) / 10;
+ return `${tenths < 10 ? tenths : Math.round(tenths)}k`;
+}