From 8f118825df22ca62cf0f08bc3a3cd7595e89d972 Mon Sep 17 00:00:00 2001 From: David S D Date: Mon, 21 Sep 2026 23:39:20 +0530 Subject: [PATCH] Ask the reader to star the repo, where they would look for it The landing page linked to the repository twice and asked for nothing either time: `Self-host` in the nav and `GitHub` in the footer's Project column both led to the same place. Each is now the star chip instead, so the two places that already pointed at the repo do the asking. A chip rather than a button, because 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 channel the reader came to create. The nav takes the short label, since the mark carries the destination; the footer has the room to spell it out, and closes the block that says the project is open source. The count is shown whatever it is, nought included. `null` from `starCount` means GitHub did not answer, which is not the same as zero and renders as no number rather than as one. Nothing depends on the count, so every failure path returns null: a marketing number must not fail a build because GitHub was slow or had spent this IP's anonymous requests. The request carries a 2.5s abort for the first and is cached for an hour against the second, which leaves the pages statically prerendered with hourly ISR. It is wrapped in React's `cache` rather than left to fetch's own memoisation, because the abort signal opts it out of that and both the nav and the footer ask within one render. GitHub's mark sits beside its only consumer rather than in `icons.tsx`, an authored set on one stroke weight and one grid, or in `client-marks.ts`, which is keyed by the agent clients that can join a channel. It is neither, and both files are worse for holding it. Co-Authored-By: Claude Opus 5 --- app/components/footer.tsx | 15 ++++--- app/components/nav.tsx | 19 +++++---- app/components/star-button.tsx | 70 +++++++++++++++++++++++++++++++++ lib/github.test.ts | 72 ++++++++++++++++++++++++++++++++++ lib/github.ts | 60 ++++++++++++++++++++++++++++ 5 files changed, 222 insertions(+), 14 deletions(-) create mode 100644 app/components/star-button.tsx create mode 100644 lib/github.test.ts create mode 100644 lib/github.ts 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 - +
+ + + 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`; +}